Text only. No behaviour change — same conjunction, same limits, same MISSING, same 0 W, same NOT READY. Only three sentences move.
The defect
SAFETY-37's conjunction (unchanged > 35 s AND |command| > 250 W AND |travel| > 250 W) is correct and untouched. It is satisfied by two disjoint worlds, and nothing freshness.py is handed separates them:
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
Three sentences asserted DEAD. On the live 2026-09-21 episode it was REFUSED, and part 11a measures that off the already-graded committed capture: the pack read 11 % for the whole 850 s hold, the commands in that hold were +260 / +660 / +1060 W — discharge only, and the register answered the first charge command (−400 W at 11:05:31) at 11:05:34, three seconds later, inside one 17.0 s obligation window. A register that answers a reversed command in one window had not stopped reporting. What would not move was the pack.
The readiness row was the worse of the two: 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.
Before → after
1. app/freshness.py_grade() (lines 567–575 before, not 344–352 — the brief's line numbers were from an earlier tree)
…moved 800 W to +1600 W - the reading is not following, so it is reported as MISSING…
…moved 800 W to +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 rather than as a measurement (limits: …)
2. app/main.py_read_unit(), the SAFETY-37 log line — a THIRD site the brief did not list. The accusatory clause the brief quotes as "live text" is not in freshness.py at all; it is the _LOG.error wrapper at main.py:1793, and test_freshness.py part 10h had a check pinning it specifically so that removing it had to be a deliberate act by this card.
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.
The pair is named once, in the %s this line already interpolates. Saying it in both halves of one sentence is how an operator learns to skim it.
3. app/main.pychecks(), the readiness row (4667 before, not 4639–40)
{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 wordings, no shared helper. Two are a log line about a behaviour; the third is a banner about a named entity. A helper serving both would drop whichever half the other caller does not need, and the half it dropped would be the operator's next step.
SoC stays STATED, NOT WIRED. Part 11b drives the two worlds at 50 % and 11 % and asserts the sentence does not move one byte. The row does not branch on batt_fresh.stale either: I checked what Freshness.note() produces first, and it is already on the page — 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 it is present in REFUSED and absent in ABSENT. The same loop renders the grid and SoC rows, which have no gate; a branch per row would be a second place where "why is this None" is decided.
Behaviour is byte-identical, measured
goodwe_controller/tools/safety56_behaviour_trace.py prints a behaviour document containing no sentence — only stale, reading(), releases, held, the window bookkeeping, inputs_ok and every readiness row's (ok, warn). CI image, purged, :ro, origin/release/1.0archived out of git against this branch:
six driven controller worlds through the shipped read path
DEAD, REFUSED, ABSENT, HEALTHY, RELEASE, RETRIP
result
6030175ed2ab9a4143e86e89a71d45c8ab041ac1c00121a3e3e023cd2b08996con both
⚠️The control lives inside the tool.--comparerefuses to report "behaviour unchanged" unless the sentences halves differ. Both refusals are driven, not asserted:
the same file against itself
REFUSED: the sentences halves are IDENTICAL… exit 2
MOVE_W 250.0 → 240.0
BEHAVIOUR MOVED at 'sweep_stale_reads' / 'sweep_missing_reads' / 'sweep_trace' exit 1
this card
OK: sentences differ, behaviour is byte-identical. exit 0
The checks, and the mutation battery
test_freshness.pypart 11 builds the pair of worlds with ground truth written down, never derived from the published stream (habit 2), asserts they are really two — 40 of 40 reads differ in ground truth while the published stream is bit-identical — shows the verdict and the sentence are byte-identical across them, and only then grades the sentences. Each mutation in its own mktemp'd copy (habit 18), purged on the host, :ro, via tools/mutation_run.sh.
mutation
Δ B
named reds
M1
_grade() reverted to "the reading is not following"
−402
3
M2
log line reverted to "register is not following its own command"
−80
2
M3
row reverted to "{entity} is missing or not numeric"
−619
2
M4
register / source transposed between the explanations
0
1
M5
second explanation deleted, first kept
−216
1
M6
"CANNOT TELL THEM APART" → "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 / 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
⚠️Two of the eleven found defects in this card's own check, not in the code.
M9 originally produced 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.
The first invariance run compared a tree with itself, because it archived a branch whose edits were not committed yet. Hence the tool's built-in refusal above.
M11 is aimed at the not any(...) quantifier: empty or mistyped, it passes vacuously. A 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.
Deliberately not absorbed
SAFETY-57 is untouched except where it handed this card the baton. Its part 10h check is inverted, not deleted — it required the accusation to still be present; this is the deliberate act it was waiting for. Its escape clause, its release, its bound and §8 of its decision record are otherwise unchanged (§8 gains a one-paragraph since superseded note).
SAFETY-58 and the 710/790 disagreement are not touched. §7 of the new decision record names the fourth site at DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md:274 that a 710/790 fix written off the PR table alone would miss.
MUTATION.md is not edited. The two instrument defects above are recorded in DECISIONS/SAFETY-56-what-the-gate-cannot-tell-apart.md §5–§6 in the same language; whether either earns a habit 24 is a curation call, and concurrent cards are editing that area.
CHANGELOG.md is not edited. test_version_gate.py part 11b requires the newest ## heading to be a version equal to config.yaml's, so an ## Unreleased section would be red and a bump is PKG's call. The 0.10.1 section's statement that SAFETY-56 is not in that release stays true of 0.10.1.
What was wrong in the brief
origin/release/1.0 is 239e120 as stated — but in admin/goodwe-addon, not admin/goodwe. (I lost a few minutes cloning the wrong one.)
freshness.py's self.reason block is at 567–575, not 344–352 (the brief said as much).
The readiness row is at 4667, not 4639–40.
There are three call sites, not two. The accusatory clause in the live text the brief quotes lives in main.py:1793, not in freshness.py.
The episode's SoC is 11 %, not 9 %, measured off the capture: 9 % is the minimum across the whole 40-minute window, 11 % is what the pack read for the entire hold. Both new code comments say 11 % and cite the check that measures it.
Suite
30/30 in the CI image, checks.yml's exact command, purged tree, :ro, one file at a time, under /home/homelab/.
**Text only. No behaviour change** — same conjunction, same limits, same `MISSING`, same 0 W, same `NOT READY`. Only three sentences move.
## The defect
SAFETY-37's conjunction (`unchanged > 35 s AND |command| > 250 W AND |travel| > 250 W`) is **correct and untouched**. It is satisfied by **two disjoint worlds**, and nothing `freshness.py` is handed separates them:
| | 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 |
Three sentences asserted **DEAD**. On the live 2026-09-21 episode it was **REFUSED**, and part 11a measures that off the already-graded committed capture: the pack read **11 %** for the whole **850 s** hold, the commands in that hold were **+260 / +660 / +1060 W — discharge only**, and the register answered the first **charge** command (−400 W at 11:05:31) at **11:05:34**, three seconds later, inside one 17.0 s obligation window. A register that answers a reversed command in one window had not stopped reporting. What would not move was the pack.
The readiness row was the worse of the two: 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.
## Before → after
**1. `app/freshness.py` `_grade()`** (lines 567–575 before, not 344–352 — the brief's line numbers were from an earlier tree)
> …moved 800 W to +1600 W ~~- the reading is not following, so it is~~ reported as MISSING…
> …moved 800 W to +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 rather than as a measurement (limits: …)
**2. `app/main.py` `_read_unit()`, the `SAFETY-37` log line — a THIRD site the brief did not list.** The accusatory clause the brief quotes as "live text" is not in `freshness.py` at all; it is the `_LOG.error` wrapper at `main.py:1793`, and `test_freshness.py` part 10h had a check pinning it *specifically so that removing it had to be a deliberate act by this card*.
> 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.
The pair is named **once**, in the `%s` this line already interpolates. Saying it in both halves of one sentence is how an operator learns to skim it.
**3. `app/main.py` `checks()`, the readiness row** (4667 before, not 4639–40)
> {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 wordings, no shared helper.** Two are a log line about a *behaviour*; the third is a banner about a named *entity*. A helper serving both would drop whichever half the other caller does not need, and the half it dropped would be the operator's next step.
**SoC stays STATED, NOT WIRED.** Part 11b drives the two worlds at **50 %** and **11 %** and asserts the sentence does not move one byte. **The row does not branch on `batt_fresh.stale`** either: I checked what `Freshness.note()` produces first, and it is already on the page — `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 it is **present in REFUSED and absent in ABSENT**. The same loop renders the grid and SoC rows, which have no gate; a branch per row would be a second place where "why is this None" is decided.
## Behaviour is byte-identical, measured
`goodwe_controller/tools/safety56_behaviour_trace.py` prints a `behaviour` document containing **no sentence** — only `stale`, `reading()`, `releases`, `held`, the window bookkeeping, `inputs_ok` and every readiness row's `(ok, warn)`. CI image, purged, `:ro`, `origin/release/1.0` **archived out of git** against this branch:
| | |
|---|---|
| the committed live capture | **2390 reads**, 7 verdict edges |
| 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 |
| **result** | `6030175ed2ab9a4143e86e89a71d45c8ab041ac1c00121a3e3e023cd2b08996c` **on both** |
⚠️ **The control lives inside the tool.** `--compare` **refuses** to report "behaviour unchanged" unless the `sentences` halves differ. Both refusals are driven, not asserted:
| | |
|---|---|
| the same file against itself | `REFUSED: the sentences halves are IDENTICAL…` exit **2** |
| `MOVE_W` 250.0 → 240.0 | `BEHAVIOUR MOVED at 'sweep_stale_reads' / 'sweep_missing_reads' / 'sweep_trace'` exit **1** |
| this card | `OK: sentences differ, behaviour is byte-identical.` exit **0** |
## The checks, and the mutation battery
`test_freshness.py` **part 11** builds the pair of worlds with **ground truth written down**, never derived from the published stream (habit 2), asserts they are really two — **40 of 40 reads differ** in ground truth while the published stream is **bit-identical** — shows the verdict and the sentence are byte-identical across them, and only then grades the sentences. Each mutation in its own `mktemp`'d copy (habit 18), purged on the host, `:ro`, via `tools/mutation_run.sh`.
| | mutation | Δ B | named reds |
|---|---|---|---|
| M1 | `_grade()` reverted to *"the reading is not following"* | −402 | **3** |
| M2 | log line reverted to *"register is not following its own command"* | −80 | **2** |
| M3 | row reverted to *"{entity} is missing or not numeric"* | −619 | **2** |
| M4 | *register* / *source* transposed between the explanations | **0** | **1** |
| M5 | second explanation deleted, first kept | −216 | **1** |
| M6 | *"CANNOT TELL THEM APART"* → *"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` / `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** |
⚠️ **Two of the eleven found defects in this card's own check, not in the code.**
* **M9** originally produced 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.**
* The first invariance run **compared a tree with itself**, because it archived a branch whose edits were not committed yet. Hence the tool's built-in refusal above.
* **M11** is aimed at the `not any(...)` quantifier: empty or mistyped, it passes vacuously. A **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.
## Deliberately not absorbed
* **SAFETY-57** is untouched except where it handed this card the baton. Its part 10h check is **inverted, not deleted** — it required the accusation to still be present; this is the deliberate act it was waiting for. Its escape clause, its release, its bound and §8 of its decision record are otherwise unchanged (§8 gains a one-paragraph *since superseded* note).
* **SAFETY-58** and the **710/790** disagreement are not touched. §7 of the new decision record names the **fourth site** at `DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md:274` that a 710/790 fix written off the PR table alone would miss.
* `MUTATION.md` is **not** edited. The two instrument defects above are recorded in `DECISIONS/SAFETY-56-what-the-gate-cannot-tell-apart.md` §5–§6 in the same language; whether either earns a habit 24 is a curation call, and concurrent cards are editing that area.
* `CHANGELOG.md` is **not** edited. `test_version_gate.py` part 11b requires the newest `## ` heading to be a version equal to `config.yaml`'s, so an `## Unreleased` section would be red and a bump is PKG's call. The 0.10.1 section's statement that SAFETY-56 is *not* in that release stays true of 0.10.1.
## What was wrong in the brief
* `origin/release/1.0` is **`239e120`** as stated — but in `admin/goodwe-addon`, not `admin/goodwe`. (I lost a few minutes cloning the wrong one.)
* `freshness.py`'s `self.reason` block is at **567–575**, not 344–352 (the brief said as much).
* The readiness row is at **4667**, not 4639–40.
* **There are three call sites, not two.** The accusatory clause in the live text the brief quotes lives in `main.py:1793`, not in `freshness.py`.
* The episode's SoC is **11 %**, not 9 %, measured off the capture: 9 % is the minimum across the whole 40-minute window, 11 % is what the pack read for the entire hold. Both new code comments say 11 % and cite the check that measures it.
## Suite
**30/30** in the CI image, `checks.yml`'s exact command, purged tree, `:ro`, one file at a time, under `/home/homelab/`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
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
1. Behaviour is unchanged — reproduced independently, and closed by a second method the card does not claim
Re-ran tools/safety56_behaviour_trace.py myself on two trees archived straight out of git (origin/release/1.0 and origin/SAFETY-56, tool copied into the base tree), purged, :ro, in goodwe-addon-checks:1. I computed the sha256 of the behaviour half outside the tool rather than trusting its own print:
Every stated count reproduces exactly: 2390 capture reads, 7 verdict edges, 66 cells × 120, 1060 stale reads, 1309MISSING readings, 6 releases, six worlds. sentences halves differ, behaviour halves are equal.
And a second, independent check that covers more than the tool does. The tool honestly scopes itself to freshness.py's verdict, _read_unit()'s gating and checks()'s rows. I parsed both versions of app/freshness.py and app/main.py and replaced every string constant with <STR>:
So across the whole of both files — not just the three drivers — nothing changed but literal text and comments. No statement, no branch, no flag, no interpolation order. Note this also pins the {label}/{entity} and %s slots, since FormattedValue nodes survive the normalisation; and the %-specifier arity of the SAFETY-37 line is ['%s','%s','%.0f','%.0f'] on both sides.
The residual that AST-identity cannot see is code that reads these strings — a string can change behaviour with an identical AST if something parses it. I checked: status_payload() filters checks() on c["ok"]/c["warn"] only, never text; liveness_notes() collects stale_note() as an opaque row; nothing in app/ matches on .reason or STALE_READING. That is exactly the gap the trace tool covers, so the two methods together close it.
Without this pair of checks a reviewer would have to take "text only" on the diff's appearance, and the one thing that would not show up there — a sentence that some consumer parses — is the thing that would take 0.10.1's live gate with it.
## 1. Behaviour is unchanged — reproduced independently, and closed by a second method the card does not claim
**Re-ran `tools/safety56_behaviour_trace.py` myself** on two trees archived straight out of git (`origin/release/1.0` and `origin/SAFETY-56`, tool copied into the base tree), purged, `:ro`, in `goodwe-addon-checks:1`. I computed the sha256 of the `behaviour` half **outside** the tool rather than trusting its own print:
```
treeA (release/1.0) 6030175ed2ab9a4143e86e89a71d45c8ab041ac1c00121a3e3e023cd2b08996c
treeB (SAFETY-56) 6030175ed2ab9a4143e86e89a71d45c8ab041ac1c00121a3e3e023cd2b08996c
```
Every stated count reproduces exactly: **2390** capture reads, **7** verdict edges, **66** cells × 120, **1060** stale reads, **1309** `MISSING` readings, **6** releases, six worlds. `sentences` halves differ, `behaviour` halves are equal.
**And a second, independent check that covers more than the tool does.** The tool honestly scopes itself to `freshness.py`'s verdict, `_read_unit()`'s gating and `checks()`'s rows. I parsed both versions of `app/freshness.py` and `app/main.py` and replaced every string constant with `<STR>`:
```
app/freshness.py AST-with-strings-normalised identical: True
app/main.py AST-with-strings-normalised identical: True
```
So across the **whole of both files** — not just the three drivers — nothing changed but literal text and comments. No statement, no branch, no flag, no interpolation order. Note this also pins the `{label}`/`{entity}` and `%s` slots, since `FormattedValue` nodes survive the normalisation; and the `%`-specifier arity of the SAFETY-37 line is `['%s','%s','%.0f','%.0f']` on both sides.
**The residual that AST-identity cannot see** is code that *reads* these strings — a string can change behaviour with an identical AST if something parses it. I checked: `status_payload()` filters `checks()` on `c["ok"]`/`c["warn"]` only, never `text`; `liveness_notes()` collects `stale_note()` as an opaque row; nothing in `app/` matches on `.reason` or `STALE_READING`. That is exactly the gap the trace tool covers, so the two methods together close it.
**Without this pair of checks** a reviewer would have to take "text only" on the diff's appearance, and the one thing that would not show up there — a sentence that some consumer parses — is the thing that would take 0.10.1's live gate with it.
2. The comparison tool's two refusals are real — I drove both, and the second is a zero-byte edit
A --compare that cannot fail proves nothing, so I did not take the refusals on the card's word.
Refusal 1 — self-comparison. Ran it both ways round, head-vs-head and base-vs-base:
REFUSED: the `sentences` halves are IDENTICAL, so these two runs cannot be
evidence that behaviour survived a WORDING change ... exit 2 (both)
Refusal 2 — MOVE_W 250.0 → 240.0 in the after tree. I applied it, purged, re-ran, and compared base against the mutant:
behaviour A 6030175ed2ab...
behaviour B 341a3fa240cd...
BEHAVIOUR MOVED at 'sweep_missing_reads'
BEHAVIOUR MOVED at 'sweep_stale_reads'
BEHAVIOUR MOVED at 'sweep_trace' exit 1
Exactly the three keys and the exit code the PR names. Worth stating that 250.0 → 240.0 is a ZERO-BYTE edit — the risky delta under MUTATION.md's own reporting rule, invisible to a byte count or a file fingerprint — and the instrument caught it on three independent keys. That is the strongest evidence in the card that the identity in comment 1 is an identity that could have broken.
Without driving these, "behaviour byte-identical" is an identity measured against itself — which is precisely the defect this card's first invariance run actually had, and which the tool now refuses structurally rather than by convention.
## 2. The comparison tool's two refusals are real — I drove both, and the second is a zero-byte edit
A `--compare` that cannot fail proves nothing, so I did not take the refusals on the card's word.
**Refusal 1 — self-comparison.** Ran it both ways round, head-vs-head and base-vs-base:
```
REFUSED: the `sentences` halves are IDENTICAL, so these two runs cannot be
evidence that behaviour survived a WORDING change ... exit 2 (both)
```
**Refusal 2 — `MOVE_W` 250.0 → 240.0** in the after tree. I applied it, purged, re-ran, and compared base against the mutant:
```
behaviour A 6030175ed2ab...
behaviour B 341a3fa240cd...
BEHAVIOUR MOVED at 'sweep_missing_reads'
BEHAVIOUR MOVED at 'sweep_stale_reads'
BEHAVIOUR MOVED at 'sweep_trace' exit 1
```
Exactly the three keys and the exit code the PR names. **Worth stating that `250.0` → `240.0` is a ZERO-BYTE edit** — the risky delta under MUTATION.md's own reporting rule, invisible to a byte count or a file fingerprint — and the instrument caught it on three independent keys. That is the strongest evidence in the card that the identity in comment 1 is an identity that *could* have broken.
**Without driving these**, "behaviour byte-identical" is an identity measured against itself — which is precisely the defect this card's first invariance run actually had, and which the tool now refuses structurally rather than by convention.
3. Mutations: I re-applied five on fresh purged trees. All five reproduce the claimed named-red count exactly
Each on its own cp -a of the head tree, __pycache__ and *.pyc purged on the host, mounted :ro, run in goodwe-addon-checks:1. Baseline first: head tree green, 113 checks graded.
mutation
Δ B (mine)
named reds claimed
named reds measured
M4
register/source transposed between the two explanations
0
1
1 — 11c. BOTH EXPLANATIONS ARE NAMED, AND IN THAT ORDER
M6
CANNOT TELL THEM APART → LISTS THEM IN ORDER OF LIKELIHOOD
+11
1
1 — the "says in its own words that it CANNOT choose" check
M9
label/entity transposed in the row
0
3
3
M10
both files reverted wholesale to 0.10.1
−5790
5
5
M11
the three accusation literals mistyped
0
1
1 — the POSITIVE CONTROL
Three of the five are zero-byte edits. No byte delta, no tree fingerprint and no shape pin can see any of them, so they are the only ones that actually grade the sentence-level checks — and each one is caught by a check that prints what it saw (M4's red reads ... at -1 then ... at -1, habit 3).
The two aimed inward both work.
M11 is the vacuity control. I mistyped all three literals (readinq, reqister, missinq) — a change a careless edit really could make — and the not any(...) check below it went on passing, exactly as the card warns. The POSITIVE CONTROL is the single red. It is a two-way quantifier (every shipped sentence contains a literal and every literal matches a shipped sentence), which is what kills both the empty-list and the mistyped-literal cases. Correctly built.
M9 / FCST-06. This is the one I most wanted to confirm, and it holds: 113 checks graded under M9, the same 113 as the clean baseline. The transposed row is unfindable, _s56_row() returns the named absence, the PRECONDITION goes red by name, and every later check is still graded. No IndexError, no truncated run. The only occurrences of "IndexError" in the output are inside the check's own description text.
M10 note. I ran a stronger M10 than the card's: rather than reverting the three sentences, I copied app/freshness.py and app/main.py wholesale from release/1.0, so the comments revert too. Hence −5790 B against the card's −1101 B. Still exactly 5 named reds, so the claim holds a fortiori, and this version has the merit of being literally the 0.10.1 artifact rather than a reconstruction of it.
Without re-running these, a reader would be trusting a table — and two of the eleven entries in that table exist because the first attempt at them was wrong, which is the best available reason not to trust the table on sight.
## 3. Mutations: I re-applied five on fresh purged trees. All five reproduce the claimed named-red count exactly
Each on its own `cp -a` of the head tree, `__pycache__` and `*.pyc` purged on the host, mounted `:ro`, run in `goodwe-addon-checks:1`. Baseline first: head tree **green, 113 checks graded**.
| | mutation | Δ B (mine) | named reds claimed | named reds measured |
|---|---|---|---|---|
| M4 | `register`/`source` transposed between the two explanations | **0** | 1 | **1** — `11c. BOTH EXPLANATIONS ARE NAMED, AND IN THAT ORDER` |
| M6 | `CANNOT TELL THEM APART` → `LISTS THEM IN ORDER OF LIKELIHOOD` | +11 | 1 | **1** — the "says in its own words that it CANNOT choose" check |
| M9 | `label`/`entity` transposed in the row | **0** | 3 | **3** |
| M10 | both files reverted wholesale to 0.10.1 | −5790 | 5 | **5** |
| M11 | the three accusation literals mistyped | **0** | 1 | **1** — the POSITIVE CONTROL |
Three of the five are **zero-byte** edits. No byte delta, no tree fingerprint and no shape pin can see any of them, so they are the only ones that actually grade the sentence-level checks — and each one is caught by a check that *prints what it saw* (M4's red reads `... at -1 then ... at -1`, habit 3).
**The two aimed inward both work.**
* **M11** is the vacuity control. I mistyped all three literals (`readinq`, `reqister`, `missinq`) — a change a careless edit really could make — and the `not any(...)` check below it went on passing, exactly as the card warns. The **POSITIVE CONTROL** is the single red. It is a two-way quantifier (every shipped sentence contains a literal **and** every literal matches a shipped sentence), which is what kills both the empty-list and the mistyped-literal cases. Correctly built.
* **M9 / FCST-06.** This is the one I most wanted to confirm, and it holds: **113 checks graded under M9, the same 113 as the clean baseline.** The transposed row is unfindable, `_s56_row()` returns the named absence, the PRECONDITION goes red *by name*, and every later check is still graded. No `IndexError`, no truncated run. The only occurrences of "IndexError" in the output are inside the check's own description text.
**M10 note.** I ran a *stronger* M10 than the card's: rather than reverting the three sentences, I copied `app/freshness.py` and `app/main.py` wholesale from `release/1.0`, so the comments revert too. Hence −5790 B against the card's −1101 B. Still exactly **5** named reds, so the claim holds a fortiori, and this version has the merit of being literally the 0.10.1 artifact rather than a reconstruction of it.
**Without re-running these**, a reader would be trusting a table — and two of the eleven entries in that table exist *because the first attempt at them was wrong*, which is the best available reason not to trust the table on sight.
4. The SoC correction is right and the brief's record is wrong — measured off the capture, four numbers at once
I decoded scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz myself rather than reading part 11a's assertions.
SoC. Across the whole 40-minute capture, soc_pct runs min=9.0 max=12.0 — so 9 % is the whole-window minimum, as the card says. Isolating the register hold (the maximal run of batt_w == -3.0, 10:51:24 → 11:05:24):
SoC values in hold: [11.0] <- one distinct value, for the entire hold
11 % is correct. The 9 % in the brief is the whole-capture minimum being mistaken for the hold value. Independent corroboration: CHANGELOG.md:98, already shipped in 0.10.1, reads "0x0518 reaches −3 W. The pack is at 11 %" — so the code comments and the shipped changelog agree, and the card's correction restores the record rather than changing it.
The other three numbers in the same breath. Setpoint changes around the hold:
Commands during the hold: discharge only. Distinct setpoints in the window are {0, +660, +1060}, with +260 standing from 10:51:19 — five seconds before the first -3.0 sample, i.e. inside the standing-command window the gate grades against (the reading last moved at 10:51:14). So +260 / +660 / +1060 is right under the gate's own definition of the window. No negative setpoint anywhere in the hold.
The first charge command is -400 W at 11:05:31, and the register answered at 11:05:34 — 3 s, well inside one 17.0 s obligation window. Exactly as claimed.
790 s falls out of the same table: 10:52:21 → 11:05:31 is 790 s to the second — the SAFETY-57 park.
The hold measures 840 s between first and last -3.0 sample; "850 s" is the same interval bounded by the adjacent differing samples on a 10 s publish. Not a discrepancy.
What a reader would otherwise do: carry 9 % forward. It matters because 9 % vs 11 % is the difference between "the pack was at the very bottom" and "the pack sat at a steady floor the BMS was holding" — and the second is the fact that makes the register's honesty the whole point of the card.
## 4. The SoC correction is right and the brief's record is wrong — measured off the capture, four numbers at once
I decoded `scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz` myself rather than reading part 11a's assertions.
**SoC.** Across the whole 40-minute capture, `soc_pct` runs `min=9.0 max=12.0` — so **9 % is the whole-window minimum**, as the card says. Isolating the register hold (the maximal run of `batt_w == -3.0`, 10:51:24 → 11:05:24):
```
SoC values in hold: [11.0] <- one distinct value, for the entire hold
```
**11 % is correct. The 9 % in the brief is the whole-capture minimum being mistaken for the hold value.** Independent corroboration: `CHANGELOG.md:98`, already shipped in 0.10.1, reads *"0x0518 reaches −3 W. The pack is at 11 %"* — so the code comments and the shipped changelog agree, and the card's correction restores the record rather than changing it.
**The other three numbers in the same breath.** Setpoint changes around the hold:
```
10:51:19 +260 10:52:21 +0
10:51:25 +660 11:05:31 -400 <- first CHARGE
10:51:29 +1060 11:05:33 -800
```
* **Commands during the hold: discharge only.** Distinct setpoints in the window are `{0, +660, +1060}`, with `+260` standing from 10:51:19 — five seconds before the first `-3.0` sample, i.e. *inside* the standing-command window the gate grades against (the reading last moved at 10:51:14). So `+260 / +660 / +1060` is right under the gate's own definition of the window. **No negative setpoint anywhere in the hold.**
* **The first charge command is `-400 W` at `11:05:31`**, and the register answered at `11:05:34` — **3 s**, well inside one 17.0 s obligation window. Exactly as claimed.
* **790 s falls out of the same table**: 10:52:21 → 11:05:31 is 790 s to the second — the SAFETY-57 park.
The hold measures **840 s** between first and last `-3.0` sample; "850 s" is the same interval bounded by the adjacent differing samples on a 10 s publish. Not a discrepancy.
**What a reader would otherwise do:** carry 9 % forward. It matters because 9 % vs 11 % is the difference between "the pack was at the very bottom" and "the pack sat at a steady floor the BMS was holding" — and the second is the fact that makes the register's honesty the whole point of the card.
5. The third call site is real, inverting was the right call, and no fourth runtime site survives — but there is a fourth documentation surface
The third site is real. At release/1.0, git grep over the whole repo puts the accusatory clause the brief quoted as "the live text" in exactly one place: app/main.py:1793, the _LOG.error wrapper — not in freshness.py. The brief's other two line numbers also check out against the base tree: app/main.py:4667 for the readiness row, and freshness.py's self.reason block spanning 567–575 with the accusation itself on 571. Repo name admin/goodwe-addon confirmed (the clone works).
Inverting part 10h rather than deleting it was right, and I can put a number on why. Part 10h existed to require the accusation to still be present, so that removing it had to be a deliberate act. Deleting the check would have thrown away the only thing that makes this card's edit distinguishable from an accidental one. Inverted, it is load-bearing in the new direction — it is one of M10's five named reds, i.e. it is the check that catches the whole card being reverted. A deleted check catches nothing.
No fourth runtime site. At the head commit, git grep over app/ for all three accusation literals returns nothing:
'is not following its own command' -> 0 hits in app/
'the reading is not following' -> 0 hits in app/
'missing or not numeric' -> only the new row's own second clause, and test/DECISIONS text
Part 11's final check states this over the four surfaces rather than the source files, which is the right unit — two of the three sentences are assembled from two modules, so a per-file grep would miss the composition.
⚠️The one thing I would put on a follow-up card, non-blocking.DOCS.md — shipped operator documentation, not a runtime sentence — carries the same accusation in its freshness triage table at line 547, and CHANGELOG.md:163 mirrors it:
| the pair repeating every ~70 s | the register really is not following, and the add-on is re-testing it | look at the unit; the site is regulating on the meter in between |
The row above it correctly says the once-and-stops case is a BMS refusal. So the table does discriminate — but it discriminates on a repeat signature, and I could not establish whether a pack held at its floor can produce that signature too. I tried to drive it and failed to reproduce the trip, so I am explicitly not asserting the table is wrong; I am flagging that it is the fourth place an operator can be told to go look at healthy hardware, and that its discriminator has not been graded by anything. It is SAFETY-57's surface and out of this card's stated scope — correctly so — but it is the obvious next site.
## 5. The third call site is real, inverting was the right call, and no fourth **runtime** site survives — but there is a fourth *documentation* surface
**The third site is real.** At `release/1.0`, `git grep` over the whole repo puts the accusatory clause the brief quoted as "the live text" in exactly one place: `app/main.py:1793`, the `_LOG.error` wrapper — **not** in `freshness.py`. The brief's other two line numbers also check out against the base tree: `app/main.py:4667` for the readiness row, and `freshness.py`'s `self.reason` block spanning 567–575 with the accusation itself on **571**. Repo name `admin/goodwe-addon` confirmed (the clone works).
**Inverting part 10h rather than deleting it was right**, and I can put a number on why. Part 10h existed to require the accusation *to still be present*, so that removing it had to be a deliberate act. Deleting the check would have thrown away the only thing that makes this card's edit distinguishable from an accidental one. Inverted, it is load-bearing in the new direction — it is **one of M10's five named reds**, i.e. it is the check that catches the whole card being reverted. A deleted check catches nothing.
**No fourth runtime site.** At the head commit, `git grep` over `app/` for all three accusation literals returns **nothing**:
```
'is not following its own command' -> 0 hits in app/
'the reading is not following' -> 0 hits in app/
'missing or not numeric' -> only the new row's own second clause, and test/DECISIONS text
```
Part 11's final check states this over the four **surfaces** rather than the source files, which is the right unit — two of the three sentences are assembled from two modules, so a per-file grep would miss the composition.
---
⚠️ **The one thing I would put on a follow-up card, non-blocking.** `DOCS.md` — shipped operator documentation, not a runtime sentence — carries the same accusation in its freshness triage table at line 547, and `CHANGELOG.md:163` mirrors it:
> | the pair repeating every ~70 s | **the register really is not following**, and the add-on is re-testing it | **look at the unit**; the site is regulating on the meter in between |
The row above it correctly says the once-and-stops case is a BMS refusal. So the table does discriminate — but it discriminates on a *repeat signature*, and I could not establish whether a pack held at its floor can produce that signature too. **I tried to drive it and failed to reproduce the trip**, so I am explicitly **not** asserting the table is wrong; I am flagging that it is the fourth place an operator can be told to go look at healthy hardware, and that its discriminator has not been graded by anything. It is `SAFETY-57`'s surface and out of this card's stated scope — correctly so — but it is the obvious next site.
6. Do the new sentences fix the operator's problem? Mostly yes — but the readiness row pays for it on two rows where the ambiguity cannot occur
This is the half that no sha256 can grade, so I read the rendered output rather than the diff.
The log line and the _grade() sentence: yes. The operator at 03:00 now reads "reading did not move under a command that did, WHICH IS NOT BY ITSELF A VERDICT ON THE REGISTER" before anything else, and the caps clause lands early enough to survive a skim. Naming the pair once, in the %s the line already interpolates, is the right call — the full line is ~700 characters and saying it twice would have been the thing that taught the operator to skip it.
The readiness row: right direction, and one real cost. Before, finding the entity working in Developer Tools contradicted the row, which is what sent the operator to conclude the diagnostics were broken. Now it confirms the second named branch. That is a genuine repair of the failure mode.
But the row is shared, and checks() renders grid power and battery SoC through the same line. I drove a world where those entities are genuinely absent:
grid power: no usable value this cycle from sensor.grid - 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
There is no freshness gate on the grid or SoC signals. For those two rows the second cause is not a live possibility, so a genuinely missing meter now gets 300 characters of hedge and a "confirm before replacing" instruction in front of a diagnosis that was simply true before. The card names this in §7 and calls it the price of not branching per row, which I think is the right trade — a per-row branch would put "why is this None" in two places, and the battery row is the one that actually fired on a live site. But it is a cost, it is paid on 2 of 3 rows, and §7 is where it is recorded rather than anywhere an operator will see it.
The one wording point I would raise. The row's fastest discriminator — "is the STALE_READING row on the page?" — sits in a parenthetical ("and names itself in a row of its own"), while the slowest one, "Confirm the entity in Developer Tools", is the closing imperative. An operator reads the last instruction and acts on it. Part 11d correctly proves the discriminator row is present in REFUSED and absent in ABSENT — it is selective, it works, it just is not where the eye lands. Promoting it to the final clause would cost nothing and would send the operator to the row that answers the question instead of to the tool that only rules out one branch.
None of this blocks: it is a wording preference on a surface that is strictly better than what it replaces, and the behaviour is identical either way.
## 6. Do the new sentences fix the operator's problem? Mostly yes — but the readiness row pays for it on two rows where the ambiguity cannot occur
This is the half that no sha256 can grade, so I read the rendered output rather than the diff.
**The log line and the `_grade()` sentence: yes.** The operator at 03:00 now reads *"reading did not move under a command that did, **WHICH IS NOT BY ITSELF A VERDICT ON THE REGISTER**"* before anything else, and the caps clause lands early enough to survive a skim. Naming the pair once, in the `%s` the line already interpolates, is the right call — the full line is ~700 characters and saying it twice would have been the thing that taught the operator to skip it.
**The readiness row: right direction, and one real cost.** Before, finding the entity working in Developer Tools *contradicted* the row, which is what sent the operator to conclude the diagnostics were broken. Now it *confirms the second named branch*. That is a genuine repair of the failure mode.
But the row is shared, and `checks()` renders **grid power** and **battery SoC** through the same line. I drove a world where those entities are genuinely absent:
```
grid power: no usable value this cycle from sensor.grid - 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
```
There is **no freshness gate on the grid or SoC signals**. For those two rows the second cause is not a live possibility, so a genuinely missing meter now gets 300 characters of hedge and a "confirm before replacing" instruction in front of a diagnosis that was simply *true* before. The card names this in §7 and calls it the price of not branching per row, which I think is the right trade — a per-row branch would put "why is this None" in two places, and the battery row is the one that actually fired on a live site. **But it is a cost, it is paid on 2 of 3 rows, and §7 is where it is recorded rather than anywhere an operator will see it.**
**The one wording point I would raise.** The row's fastest discriminator — *"is the STALE_READING row on the page?"* — sits in a **parenthetical** (*"and names itself in a row of its own"*), while the slowest one, *"Confirm the entity in Developer Tools"*, is the closing imperative. An operator reads the last instruction and acts on it. Part 11d correctly proves the discriminator row is present in REFUSED and absent in ABSENT — it is selective, it works, it just is not where the eye lands. Promoting it to the final clause would cost nothing and would send the operator to the row that answers the question instead of to the tool that only rules out one branch.
None of this blocks: it is a wording preference on a surface that is strictly better than what it replaces, and the behaviour is identical either way.
7. Scope: 923 insertions for three sentences is proportionate. The two omissions have reasons that are TRUE, which I checked rather than accepted
tools/safety56_behaviour_trace.py (300 lines) earns its place, and comment 1 is the argument. An AST comparison proves no code changed; it cannot prove that no consumer of the changed strings changed behaviour. The trace tool is what closes that, and it is the only artifact in the card that caught a zero-byte behaviour change (MOVE_W 250→240) on three independent keys. Its --compare refusal is also reusable: any future text-only edit to these three surfaces now has a ready-made gate, and the refusal means that gate cannot be passed by comparing a tree with itself.
The 181-line DECISIONS file earns its place too, mostly on §7. An honest gaps list — "it does not grade English", "the grid and SoC rows have no gate so the second cause is a hypothesis for them", "it does not show the episode was a BMS refusal beyond doubt" — is worth more than the rest of the file, and it is where I found the cost in comment 6. That is MUTATION.md habit 13 working as intended.
Both omissions verified rather than taken on trust (habit 22 — a stated reason is a claim).
CHANGELOG.md untouched. The stated reason is that test_version_gate.py part 11b would go red on an ## Unreleased section. I read it. It genuinely asserts _top_name.lower() != "unreleased"and_top_name == shipped_version(HERE), against config.yaml's version: "0.10.1" and a newest heading of ## 0.10.1. The reason is true, and a bump is correctly PKG's call.
MUTATION.md untouched. Both instrument defects are recorded in the DECISIONS file §5–§6 in habit language, and concurrent cards are editing that file. Reasonable. Note for the record that the M9 defect is a fourth instance of the FCST-06 "red with no name attached" family and the M11 defect is a fresh instance of the vacuous-quantifier family — whoever curates habit 24 should see both.
⚠️One follow-up for PKG, not a defect in this card.config.yaml stays at 0.10.1 and the ## 0.10.1 changelog section says, under WHAT YOU WILL SEE, that the diagnosis half "still says 'is not following its own command' ... deliberately untouched here". That stays true of 0.10.1 the released artifact — the PR's claim is accurate as worded — but it will be false of release/1.0 HEAD the moment this merges. That is a structural consequence of the version gate forbidding an ## Unreleased section, not of this card, and every card merged between bumps has it. It just needs to be picked up when the version next moves.
## 7. Scope: 923 insertions for three sentences is proportionate. The two omissions have reasons that are TRUE, which I checked rather than accepted
**`tools/safety56_behaviour_trace.py` (300 lines) earns its place**, and comment 1 is the argument. An AST comparison proves no *code* changed; it cannot prove that no *consumer* of the changed strings changed behaviour. The trace tool is what closes that, and it is the only artifact in the card that caught a **zero-byte** behaviour change (`MOVE_W` 250→240) on three independent keys. Its `--compare` refusal is also reusable: any future text-only edit to these three surfaces now has a ready-made gate, and the refusal means that gate cannot be passed by comparing a tree with itself.
**The 181-line DECISIONS file earns its place too**, mostly on §7. An honest gaps list — "it does not grade English", "the grid and SoC rows have no gate so the second cause is a hypothesis for them", "it does not show the episode was a BMS refusal beyond doubt" — is worth more than the rest of the file, and it is where I found the cost in comment 6. That is MUTATION.md habit 13 working as intended.
**Both omissions verified rather than taken on trust (habit 22 — a stated reason is a claim).**
* **`CHANGELOG.md` untouched.** The stated reason is that `test_version_gate.py` part 11b would go red on an `## Unreleased` section. I read it. It genuinely asserts `_top_name.lower() != "unreleased"` **and** `_top_name == shipped_version(HERE)`, against `config.yaml`'s `version: "0.10.1"` and a newest heading of `## 0.10.1`. **The reason is true**, and a bump is correctly PKG's call.
* **`MUTATION.md` untouched.** Both instrument defects are recorded in the DECISIONS file §5–§6 in habit language, and concurrent cards are editing that file. Reasonable. Note for the record that the M9 defect is a **fourth** instance of the FCST-06 "red with no name attached" family and the M11 defect is a fresh instance of the vacuous-quantifier family — whoever curates habit 24 should see both.
⚠️ **One follow-up for PKG, not a defect in this card.** `config.yaml` stays at `0.10.1` and the `## 0.10.1` changelog section says, under **WHAT YOU WILL SEE**, that the diagnosis half *"still says 'is not following its own command' ... deliberately untouched here"*. That stays true of **0.10.1 the released artifact** — the PR's claim is accurate as worded — but it will be false of `release/1.0` HEAD the moment this merges. That is a structural consequence of the version gate forbidding an `## Unreleased` section, not of this card, and every card merged between bumps has it. It just needs to be picked up when the version next moves.
8. CI-10: this green is this pull request's own. Traced to the payload, not taken from the rollup
Measured on PR #102 today, a success on checks / unit (pull_request) was produced by a run whose payload carried PRNUM: 111. Statuses here are keyed by head SHA with nothing binding them to the PR that produced them, and at required_approvals: 0 the context is the only gate. So I traced it.
The status arrived pending at 21:03:43 and only went success at 21:11:44 — I watched it flip, which already rules out CI-9's laundered skipped (those arrive green with target_url: "", no run and no job).
GET /actions/runs/823:
path: checks.yml@refs/pull/112/head <- names PR 112
head_sha: 8a6acd9fa201079a56777b82349f0431aebf2d2b
display_title: SAFETY-56: the gate cannot tell a dead register ...
event: pull_request run_attempt: 1 status: completed
GET /actions/runs/823/jobs — job 1948, runner goodwe-rig (id 13), conclusion success, and the test step ran 19:03:50 → 19:11:44 = 7 m 54 s. A real execution on a real runner.
And the job log carries the payload itself:
PRNUM: 112
BASEREF: release/1.0
base release/1.0 239e120bf626bb69bc76d98af5296715578ebb55
head refs/pull/112/head 8a6acd9fa201079a56777b82349f0431aebf2d2b
GRADED: refs/pull/112/head merged into release/1.0
OK: the `on:` block of the merged checks.yml carries no event filter.
PR 112, base release/1.0, and both SHAs match this pull request exactly (239e120 is the PR's base SHA, 8a6acd9 its head). It graded the merge, not the branch. It ran all 30 test files, every one PASS, on python 3.14.7 / aiohttp 3.13.5.
Independent confirmation on my own hardware: I ran checks.yml's exact command against a purged :ro archive of the head commit in goodwe-addon-checks:1 — 30/30 PASS, SUITE_EXIT=0, timings within a second or two of CI's throughout. So the green is not only this PR's own, it is reproducible off the forge entirely.
## 8. CI-10: this green is **this** pull request's own. Traced to the payload, not taken from the rollup
Measured on PR #102 today, a `success` on `checks / unit (pull_request)` was produced by a run whose payload carried `PRNUM: 111`. Statuses here are keyed by head SHA with nothing binding them to the PR that produced them, and at `required_approvals: 0` the context is the only gate. So I traced it.
The status arrived **pending** at 21:03:43 and only went `success` at **21:11:44** — I watched it flip, which already rules out CI-9's laundered `skipped` (those arrive green with `target_url: ""`, no run and no job).
`GET /actions/runs/823`:
```
path: checks.yml@refs/pull/112/head <- names PR 112
head_sha: 8a6acd9fa201079a56777b82349f0431aebf2d2b
display_title: SAFETY-56: the gate cannot tell a dead register ...
event: pull_request run_attempt: 1 status: completed
```
`GET /actions/runs/823/jobs` — job **1948**, runner `goodwe-rig` (id 13), conclusion `success`, and the test step ran **19:03:50 → 19:11:44 = 7 m 54 s**. A real execution on a real runner.
And the job log carries the payload itself:
```
PRNUM: 112
BASEREF: release/1.0
base release/1.0 239e120bf626bb69bc76d98af5296715578ebb55
head refs/pull/112/head 8a6acd9fa201079a56777b82349f0431aebf2d2b
GRADED: refs/pull/112/head merged into release/1.0
OK: the `on:` block of the merged checks.yml carries no event filter.
```
**PR 112, base `release/1.0`, and both SHAs match this pull request exactly** (`239e120` is the PR's base SHA, `8a6acd9` its head). It graded the **merge**, not the branch. It ran all **30** test files, every one `PASS`, on python 3.14.7 / aiohttp 3.13.5.
**Independent confirmation on my own hardware:** I ran `checks.yml`'s exact command against a purged `:ro` archive of the head commit in `goodwe-addon-checks:1` — **30/30 PASS, SUITE_EXIT=0**, timings within a second or two of CI's throughout. So the green is not only this PR's own, it is reproducible off the forge entirely.
9. Part 11 is sound, and §7's "it does not grade English" needs a size next to it — I measured one: +34 B
Part 11 is real work and mostly measures what it says. Confirmed by driving it: 10h is inverted, not weakened — it is not a bare not in, it also positively pins "did not move under a command that did", so it cannot pass on a tree where the line is blank or never fires; it is one of M10's five reds. _s56_row() returns the named absence and 113 of 113 checks stay graded under the zero-byte transposition. 11d's discriminator is genuinely selective. 11a is the strongest new part — its assertions are against literals (min == 260.0, max == 1060.0, _S56_SOC_HELD == [11.0], _HOLD_W == -3.0) over values derived from the committed capture, which is what makes this card a claim about the world rather than about strings.
On 11b's "40 of 40 differ" preconditions. They compare two literal lists defined four lines apart, so no mutation in app/ can redden them. That is not a defect — a precondition's job under habit 1 is to guard the fixture, and it does: I set _S56_TRUE_REFUSED = list(_S56_CMD) (the card's M7) and got 2 named reds, printing their ground truth differs on 0 of 40 reads — habit 3, it prints what it saw. Worth being clear in one's head that 11b's load-bearing assertion is the one after it (same stale, same batt is None, same inputs_ok, _r56_dead == _r56_ref), which is a real behavioural measurement.
⚠️The finding that matters. §7 says "It does not grade English. A sentence that named both explanations and then said something useless about them would pass." That is honest and it is the right gap to declare — but habit 13 asks for the gap WITH A SIZE, and this one has none. So I measured it.
I appended a fresh, differently-worded accusation to the _grade() sentence:
... the number cannot be read as this command's effect. THE REGISTER IS PLAINLY AT FAULT, so it is ...
+34 B. test_freshness.py exit 0, 113 checks graded, 0 named reds. The whole 30-file suite stays green.
The not any(...) check grades the absence of three specific literals, so a byte-identical revert is caught on every surface (M1/M2/M3/M10, all red by name) — its printed claim, "goes red if any of the three sentences is reverted", is accurate and not overstated. What it cannot reach is the accusation re-added in new words, and no substring predicate ever could; you cannot grade "this sentence blames nobody" by in. That is a limit of the method, not a mistake in this implementation.
Why it is worth writing down anyway: the sentence this card exists to fix was itself a re-wording, not a revert. The next one will be too. A reader of §7's single line would not guess that re-introducing a full accusation costs +34 bytes and leaves the suite entirely green — and that number is the difference between a declared gap and a gap somebody actually defends against. I would put the measurement into §7 rather than build anything new.
Not blocking: behaviour is unchanged, the gap is declared, and the revert case — the one a careless edit actually produces — is caught by name on all three sites.
## 9. Part 11 is sound, and §7's "it does not grade English" needs a **size** next to it — I measured one: **+34 B**
Part 11 is real work and mostly measures what it says. Confirmed by driving it: **10h is inverted, not weakened** — it is not a bare `not in`, it also positively pins `"did not move under a command that did"`, so it cannot pass on a tree where the line is blank or never fires; it is one of M10's five reds. `_s56_row()` returns the named absence and **113 of 113 checks stay graded** under the zero-byte transposition. 11d's discriminator is genuinely selective. **11a is the strongest new part** — its assertions are against literals (`min == 260.0`, `max == 1060.0`, `_S56_SOC_HELD == [11.0]`, `_HOLD_W == -3.0`) over values derived from the committed capture, which is what makes this card a claim about the world rather than about strings.
**On 11b's "40 of 40 differ" preconditions.** They compare two literal lists defined four lines apart, so no mutation in `app/` can redden them. That is not a defect — a precondition's job under habit 1 is to guard the **fixture**, and it does: I set `_S56_TRUE_REFUSED = list(_S56_CMD)` (the card's M7) and got **2 named reds**, printing `their ground truth differs on 0 of 40 reads` — habit 3, it prints what it saw. Worth being clear in one's head that 11b's *load-bearing* assertion is the one after it (same `stale`, same `batt is None`, same `inputs_ok`, `_r56_dead == _r56_ref`), which is a real behavioural measurement.
⚠️ **The finding that matters. §7 says "It does not grade English. A sentence that named both explanations and then said something useless about them would pass." That is honest and it is the right gap to declare — but habit 13 asks for the gap WITH A SIZE, and this one has none.** So I measured it.
I appended a fresh, differently-worded accusation to the `_grade()` sentence:
```
... the number cannot be read as this command's effect. THE REGISTER IS PLAINLY AT FAULT, so it is ...
```
**+34 B. `test_freshness.py` exit 0, 113 checks graded, 0 named reds. The whole 30-file suite stays green.**
The `not any(...)` check grades the absence of **three specific literals**, so a byte-identical revert is caught on every surface (M1/M2/M3/M10, all red by name) — its printed claim, *"goes red if any of the three sentences is reverted"*, is accurate and not overstated. What it cannot reach is the accusation **re-added in new words**, and no substring predicate ever could; you cannot grade "this sentence blames nobody" by `in`. That is a limit of the method, not a mistake in this implementation.
**Why it is worth writing down anyway:** the sentence this card exists to fix was itself a re-wording, not a revert. The next one will be too. A reader of §7's single line would not guess that re-introducing a full accusation costs **+34 bytes and leaves the suite entirely green** — and that number is the difference between a declared gap and a gap somebody actually defends against. I would put the measurement into §7 rather than build anything new.
**Not blocking:** behaviour is unchanged, the gap is declared, and the revert case — the one a careless edit actually produces — is caught by name on all three sites.
10. Pre-existing, not yours — but an FCST-06 abort sits six lines above the docstring written to prevent it
_s56_row()'s docstring is the best thing in this card's test code:
⚠️ IT MUST NOT RAISE, AND THAT IS FCST-06'S RULE RATHER THAN TIDINESS. ... The run then died on an IndexError with every check after that line UNGRADED: a red with no name attached, which reads like broken infrastructure and gets retried rather than read.
At test_freshness.py:1598-1599, immediately above the part 10h check this PR edits, unchanged from release/1.0:
I renamed the tag on that one log call — SAFETY-37: → SAFETY-38:, a zero-byte edit:
exit=1 graded=91 of 113 named reds: 0
Traceback (most recent call last):
File "/w/goodwe_controller/test_freshness.py", line 1599, in <module>
IndexError: list index out of range
The entirety of part 11 — this card's whole grading surface — goes ungraded, and the red carries no name. CI would report FAIL test_freshness.py with a traceback, which is precisely the shape MUTATION.md says gets retried rather than read.
This is pre-existing and I am not asking this card to fix it. It is identical in release/1.0, it belongs to SAFETY-57's block, and widening scope on a text-only card to chase it would be the wrong call. Two reasons to record it:
It is the fifth instance of the FCST-06 family in this repository, and the card already fixed the fourth (M9) eight lines below it. _s56_row()'s named-absence pattern is the fix and it is already written — applying it to _stale57 is a two-line change for whoever next opens this file.
It means the protection this card's mutation battery demonstrates has a zero-byte bypass in the block above it. Nothing in the battery could have found it, because every mutation in the battery targets the sentences rather than the log tag they hang off.
Worth a line in whatever card next touches test_freshness.py's part 10.
## 10. Pre-existing, not yours — but an FCST-06 abort sits **six lines above** the docstring written to prevent it
`_s56_row()`'s docstring is the best thing in this card's test code:
> ⚠️ IT MUST NOT RAISE, AND THAT IS FCST-06'S RULE RATHER THAN TIDINESS. ... The run then died on an IndexError with every check after that line UNGRADED: a red with **no name attached**, which reads like broken infrastructure and gets retried rather than read.
At `test_freshness.py:1598-1599`, immediately above the part 10h check this PR edits, unchanged from `release/1.0`:
```python
_stale57 = [m for m in _msgs57 if "SAFETY-37" in m]
print(f" escape clause: ...{_stale57[0][-150:]}")
```
I renamed the tag on that one log call — `SAFETY-37:` → `SAFETY-38:`, a **zero-byte** edit:
```
exit=1 graded=91 of 113 named reds: 0
Traceback (most recent call last):
File "/w/goodwe_controller/test_freshness.py", line 1599, in <module>
IndexError: list index out of range
```
**The entirety of part 11 — this card's whole grading surface — goes ungraded, and the red carries no name.** CI would report `FAIL test_freshness.py` with a traceback, which is precisely the shape MUTATION.md says gets retried rather than read.
**This is pre-existing and I am not asking this card to fix it.** It is identical in `release/1.0`, it belongs to SAFETY-57's block, and widening scope on a text-only card to chase it would be the wrong call. Two reasons to record it:
1. It is the **fifth** instance of the FCST-06 family in this repository, and the card already fixed the fourth (M9) eight lines below it. `_s56_row()`'s named-absence pattern is the fix and it is already written — applying it to `_stale57` is a two-line change for whoever next opens this file.
2. It means the protection this card's mutation battery demonstrates has a **zero-byte** bypass in the block above it. Nothing in the battery could have found it, because every mutation in the battery targets the sentences rather than the log tag they hang off.
Worth a line in whatever card next touches `test_freshness.py`'s part 10.
Behaviour did not move, and that was the one thing that could block. Established twice over, independently of the card's own claims: the trace tool reproduces 6030175ed2ab… on both trees with every stated count exact, and an AST comparison with string constants normalised shows app/freshness.py and app/main.py are structurally identical across the whole of both files. Nothing in app/ parses any of the three strings, which closes the one gap AST-identity leaves. The tool's controls are real — I drove both refusals, and the MOVE_W one is a zero-byte edit caught on three keys.
Everything the card asserts, I could reproduce. Five mutations re-applied on fresh purged trees, all five matching the claimed named-red counts exactly, three of them zero-byte. 113 checks stay graded under the transposition that used to abort. 30/30 suite green on my own hardware under checks.yml's exact command. The corrections to the brief are all right: three call sites not two, main.py:1793 / main.py:4667 / freshness.py:571, admin/goodwe-addon, and 11 % — which I measured off the capture, along with +260/+660/+1060 discharge-only, the -400 W charge answered in 3 s, and 790 s to the second.
The green is this PR's own — PRNUM: 112, BASEREF: release/1.0, both SHAs matching, GRADED: refs/pull/112/head merged into release/1.0, a real 7 m 54 s execution on goodwe-rig. Traced to the run payload, not taken from the rollup.
Four things recorded, none blocking: the grid/SoC rows pay for the shared wording (#6), the accusation check's declared "does not grade English" gap is +34 B wide and that size belongs in §7 (#9), a pre-existing zero-byte FCST-06 abort sits six lines above the docstring written to prevent it (#10), and DOCS.md's triage table is a fourth operator-facing surface for a later card (#5).
The card does the thing it set out to do: three sentences that stop asserting a fact the gate cannot know, on a gate whose conjunction is correct and untouched. The operator who is woken at 03:00 is now told what was observed and what it does not prove, instead of being sent to replace working hardware.
## Verdict: sound. Merging.
**Behaviour did not move, and that was the one thing that could block.** Established twice over, independently of the card's own claims: the trace tool reproduces `6030175ed2ab…` on both trees with every stated count exact, and an AST comparison with string constants normalised shows `app/freshness.py` and `app/main.py` are **structurally identical** across the whole of both files. Nothing in `app/` parses any of the three strings, which closes the one gap AST-identity leaves. The tool's controls are real — I drove both refusals, and the `MOVE_W` one is a zero-byte edit caught on three keys.
**Everything the card asserts, I could reproduce.** Five mutations re-applied on fresh purged trees, all five matching the claimed named-red counts exactly, three of them zero-byte. 113 checks stay graded under the transposition that used to abort. 30/30 suite green on my own hardware under `checks.yml`'s exact command. The corrections to the brief are all right: three call sites not two, `main.py:1793` / `main.py:4667` / `freshness.py:571`, `admin/goodwe-addon`, and **11 %** — which I measured off the capture, along with `+260/+660/+1060` discharge-only, the `-400 W` charge answered in 3 s, and 790 s to the second.
**The green is this PR's own** — `PRNUM: 112`, `BASEREF: release/1.0`, both SHAs matching, `GRADED: refs/pull/112/head merged into release/1.0`, a real 7 m 54 s execution on `goodwe-rig`. Traced to the run payload, not taken from the rollup.
**Four things recorded, none blocking:** the grid/SoC rows pay for the shared wording (#6), the accusation check's declared "does not grade English" gap is **+34 B** wide and that size belongs in §7 (#9), a pre-existing zero-byte FCST-06 abort sits six lines above the docstring written to prevent it (#10), and `DOCS.md`'s triage table is a fourth operator-facing surface for a later card (#5).
The card does the thing it set out to do: three sentences that stop asserting a fact the gate cannot know, on a gate whose conjunction is correct and untouched. The operator who is woken at 03:00 is now told what was observed and what it does not prove, instead of being sent to replace working hardware.
admin
merged commit c9aaf7f04d into release/1.02026-09-23 21:27:41 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Text only. No behaviour change — same conjunction, same limits, same
MISSING, same 0 W, sameNOT READY. Only three sentences move.The defect
SAFETY-37's conjunction (
unchanged > 35 s AND |command| > 250 W AND |travel| > 250 W) is correct and untouched. It is satisfied by two disjoint worlds, and nothingfreshness.pyis handed separates them:Three sentences asserted DEAD. On the live 2026-09-21 episode it was REFUSED, and part 11a measures that off the already-graded committed capture: the pack read 11 % for the whole 850 s hold, the commands in that hold were +260 / +660 / +1060 W — discharge only, and the register answered the first charge command (−400 W at 11:05:31) at 11:05:34, three seconds later, inside one 17.0 s obligation window. A register that answers a reversed command in one window had not stopped reporting. What would not move was the pack.
The readiness row was the worse of the two: 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.
Before → after
1.
app/freshness.py_grade()(lines 567–575 before, not 344–352 — the brief's line numbers were from an earlier tree)2.
app/main.py_read_unit(), theSAFETY-37log line — a THIRD site the brief did not list. The accusatory clause the brief quotes as "live text" is not infreshness.pyat all; it is the_LOG.errorwrapper atmain.py:1793, andtest_freshness.pypart 10h had a check pinning it specifically so that removing it had to be a deliberate act by this card.The pair is named once, in the
%sthis line already interpolates. Saying it in both halves of one sentence is how an operator learns to skim it.3.
app/main.pychecks(), the readiness row (4667 before, not 4639–40)Three wordings, no shared helper. Two are a log line about a behaviour; the third is a banner about a named entity. A helper serving both would drop whichever half the other caller does not need, and the half it dropped would be the operator's next step.
SoC stays STATED, NOT WIRED. Part 11b drives the two worlds at 50 % and 11 % and asserts the sentence does not move one byte. The row does not branch on
batt_fresh.staleeither: I checked whatFreshness.note()produces first, and it is already on the page —stale_note()ridesliveness_notes()a few rows below with the wholeSTALE_READINGsentence in it. So the operator's discriminator is "is that row there?", and part 11d asserts it is present in REFUSED and absent in ABSENT. The same loop renders the grid and SoC rows, which have no gate; a branch per row would be a second place where "why is this None" is decided.Behaviour is byte-identical, measured
goodwe_controller/tools/safety56_behaviour_trace.pyprints abehaviourdocument containing no sentence — onlystale,reading(),releases,held, the window bookkeeping,inputs_okand every readiness row's(ok, warn). CI image, purged,:ro,origin/release/1.0archived out of git against this branch:MISSINGreadings, 6 releases6030175ed2ab9a4143e86e89a71d45c8ab041ac1c00121a3e3e023cd2b08996con both⚠️ The control lives inside the tool.
--comparerefuses to report "behaviour unchanged" unless thesentenceshalves differ. Both refusals are driven, not asserted:REFUSED: the sentences halves are IDENTICAL…exit 2MOVE_W250.0 → 240.0BEHAVIOUR MOVED at 'sweep_stale_reads' / 'sweep_missing_reads' / 'sweep_trace'exit 1OK: sentences differ, behaviour is byte-identical.exit 0The checks, and the mutation battery
test_freshness.pypart 11 builds the pair of worlds with ground truth written down, never derived from the published stream (habit 2), asserts they are really two — 40 of 40 reads differ in ground truth while the published stream is bit-identical — shows the verdict and the sentence are byte-identical across them, and only then grades the sentences. Each mutation in its ownmktemp'd copy (habit 18), purged on the host,:ro, viatools/mutation_run.sh._grade()reverted to "the reading is not following"label/entitytransposed in the row⚠️ Two of the eleven found defects in this card's own check, not in the code.
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.not any(...)quantifier: empty or mistyped, it passes vacuously. A 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.Deliberately not absorbed
DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md:274that a 710/790 fix written off the PR table alone would miss.MUTATION.mdis not edited. The two instrument defects above are recorded inDECISIONS/SAFETY-56-what-the-gate-cannot-tell-apart.md§5–§6 in the same language; whether either earns a habit 24 is a curation call, and concurrent cards are editing that area.CHANGELOG.mdis not edited.test_version_gate.pypart 11b requires the newest##heading to be a version equal toconfig.yaml's, so an## Unreleasedsection would be red and a bump is PKG's call. The 0.10.1 section's statement that SAFETY-56 is not in that release stays true of 0.10.1.What was wrong in the brief
origin/release/1.0is239e120as stated — but inadmin/goodwe-addon, notadmin/goodwe. (I lost a few minutes cloning the wrong one.)freshness.py'sself.reasonblock is at 567–575, not 344–352 (the brief said as much).main.py:1793, not infreshness.py.Suite
30/30 in the CI image,
checks.yml's exact command, purged tree,:ro, one file at a time, under/home/homelab/.🤖 Generated with Claude Code
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
1. Behaviour is unchanged — reproduced independently, and closed by a second method the card does not claim
Re-ran
tools/safety56_behaviour_trace.pymyself on two trees archived straight out of git (origin/release/1.0andorigin/SAFETY-56, tool copied into the base tree), purged,:ro, ingoodwe-addon-checks:1. I computed the sha256 of thebehaviourhalf outside the tool rather than trusting its own print:Every stated count reproduces exactly: 2390 capture reads, 7 verdict edges, 66 cells × 120, 1060 stale reads, 1309
MISSINGreadings, 6 releases, six worlds.sentenceshalves differ,behaviourhalves are equal.And a second, independent check that covers more than the tool does. The tool honestly scopes itself to
freshness.py's verdict,_read_unit()'s gating andchecks()'s rows. I parsed both versions ofapp/freshness.pyandapp/main.pyand replaced every string constant with<STR>:So across the whole of both files — not just the three drivers — nothing changed but literal text and comments. No statement, no branch, no flag, no interpolation order. Note this also pins the
{label}/{entity}and%sslots, sinceFormattedValuenodes survive the normalisation; and the%-specifier arity of the SAFETY-37 line is['%s','%s','%.0f','%.0f']on both sides.The residual that AST-identity cannot see is code that reads these strings — a string can change behaviour with an identical AST if something parses it. I checked:
status_payload()filterschecks()onc["ok"]/c["warn"]only, nevertext;liveness_notes()collectsstale_note()as an opaque row; nothing inapp/matches on.reasonorSTALE_READING. That is exactly the gap the trace tool covers, so the two methods together close it.Without this pair of checks a reviewer would have to take "text only" on the diff's appearance, and the one thing that would not show up there — a sentence that some consumer parses — is the thing that would take 0.10.1's live gate with it.
2. The comparison tool's two refusals are real — I drove both, and the second is a zero-byte edit
A
--comparethat cannot fail proves nothing, so I did not take the refusals on the card's word.Refusal 1 — self-comparison. Ran it both ways round, head-vs-head and base-vs-base:
Refusal 2 —
MOVE_W250.0 → 240.0 in the after tree. I applied it, purged, re-ran, and compared base against the mutant:Exactly the three keys and the exit code the PR names. Worth stating that
250.0→240.0is a ZERO-BYTE edit — the risky delta under MUTATION.md's own reporting rule, invisible to a byte count or a file fingerprint — and the instrument caught it on three independent keys. That is the strongest evidence in the card that the identity in comment 1 is an identity that could have broken.Without driving these, "behaviour byte-identical" is an identity measured against itself — which is precisely the defect this card's first invariance run actually had, and which the tool now refuses structurally rather than by convention.
3. Mutations: I re-applied five on fresh purged trees. All five reproduce the claimed named-red count exactly
Each on its own
cp -aof the head tree,__pycache__and*.pycpurged on the host, mounted:ro, run ingoodwe-addon-checks:1. Baseline first: head tree green, 113 checks graded.register/sourcetransposed between the two explanations11c. BOTH EXPLANATIONS ARE NAMED, AND IN THAT ORDERCANNOT TELL THEM APART→LISTS THEM IN ORDER OF LIKELIHOODlabel/entitytransposed in the rowThree of the five are zero-byte edits. No byte delta, no tree fingerprint and no shape pin can see any of them, so they are the only ones that actually grade the sentence-level checks — and each one is caught by a check that prints what it saw (M4's red reads
... at -1 then ... at -1, habit 3).The two aimed inward both work.
readinq,reqister,missinq) — a change a careless edit really could make — and thenot any(...)check below it went on passing, exactly as the card warns. The POSITIVE CONTROL is the single red. It is a two-way quantifier (every shipped sentence contains a literal and every literal matches a shipped sentence), which is what kills both the empty-list and the mistyped-literal cases. Correctly built._s56_row()returns the named absence, the PRECONDITION goes red by name, and every later check is still graded. NoIndexError, no truncated run. The only occurrences of "IndexError" in the output are inside the check's own description text.M10 note. I ran a stronger M10 than the card's: rather than reverting the three sentences, I copied
app/freshness.pyandapp/main.pywholesale fromrelease/1.0, so the comments revert too. Hence −5790 B against the card's −1101 B. Still exactly 5 named reds, so the claim holds a fortiori, and this version has the merit of being literally the 0.10.1 artifact rather than a reconstruction of it.Without re-running these, a reader would be trusting a table — and two of the eleven entries in that table exist because the first attempt at them was wrong, which is the best available reason not to trust the table on sight.
4. The SoC correction is right and the brief's record is wrong — measured off the capture, four numbers at once
I decoded
scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gzmyself rather than reading part 11a's assertions.SoC. Across the whole 40-minute capture,
soc_pctrunsmin=9.0 max=12.0— so 9 % is the whole-window minimum, as the card says. Isolating the register hold (the maximal run ofbatt_w == -3.0, 10:51:24 → 11:05:24):11 % is correct. The 9 % in the brief is the whole-capture minimum being mistaken for the hold value. Independent corroboration:
CHANGELOG.md:98, already shipped in 0.10.1, reads "0x0518 reaches −3 W. The pack is at 11 %" — so the code comments and the shipped changelog agree, and the card's correction restores the record rather than changing it.The other three numbers in the same breath. Setpoint changes around the hold:
{0, +660, +1060}, with+260standing from 10:51:19 — five seconds before the first-3.0sample, i.e. inside the standing-command window the gate grades against (the reading last moved at 10:51:14). So+260 / +660 / +1060is right under the gate's own definition of the window. No negative setpoint anywhere in the hold.-400 Wat11:05:31, and the register answered at11:05:34— 3 s, well inside one 17.0 s obligation window. Exactly as claimed.The hold measures 840 s between first and last
-3.0sample; "850 s" is the same interval bounded by the adjacent differing samples on a 10 s publish. Not a discrepancy.What a reader would otherwise do: carry 9 % forward. It matters because 9 % vs 11 % is the difference between "the pack was at the very bottom" and "the pack sat at a steady floor the BMS was holding" — and the second is the fact that makes the register's honesty the whole point of the card.
5. The third call site is real, inverting was the right call, and no fourth runtime site survives — but there is a fourth documentation surface
The third site is real. At
release/1.0,git grepover the whole repo puts the accusatory clause the brief quoted as "the live text" in exactly one place:app/main.py:1793, the_LOG.errorwrapper — not infreshness.py. The brief's other two line numbers also check out against the base tree:app/main.py:4667for the readiness row, andfreshness.py'sself.reasonblock spanning 567–575 with the accusation itself on 571. Repo nameadmin/goodwe-addonconfirmed (the clone works).Inverting part 10h rather than deleting it was right, and I can put a number on why. Part 10h existed to require the accusation to still be present, so that removing it had to be a deliberate act. Deleting the check would have thrown away the only thing that makes this card's edit distinguishable from an accidental one. Inverted, it is load-bearing in the new direction — it is one of M10's five named reds, i.e. it is the check that catches the whole card being reverted. A deleted check catches nothing.
No fourth runtime site. At the head commit,
git grepoverapp/for all three accusation literals returns nothing:Part 11's final check states this over the four surfaces rather than the source files, which is the right unit — two of the three sentences are assembled from two modules, so a per-file grep would miss the composition.
⚠️ The one thing I would put on a follow-up card, non-blocking.
DOCS.md— shipped operator documentation, not a runtime sentence — carries the same accusation in its freshness triage table at line 547, andCHANGELOG.md:163mirrors it:The row above it correctly says the once-and-stops case is a BMS refusal. So the table does discriminate — but it discriminates on a repeat signature, and I could not establish whether a pack held at its floor can produce that signature too. I tried to drive it and failed to reproduce the trip, so I am explicitly not asserting the table is wrong; I am flagging that it is the fourth place an operator can be told to go look at healthy hardware, and that its discriminator has not been graded by anything. It is
SAFETY-57's surface and out of this card's stated scope — correctly so — but it is the obvious next site.6. Do the new sentences fix the operator's problem? Mostly yes — but the readiness row pays for it on two rows where the ambiguity cannot occur
This is the half that no sha256 can grade, so I read the rendered output rather than the diff.
The log line and the
_grade()sentence: yes. The operator at 03:00 now reads "reading did not move under a command that did, WHICH IS NOT BY ITSELF A VERDICT ON THE REGISTER" before anything else, and the caps clause lands early enough to survive a skim. Naming the pair once, in the%sthe line already interpolates, is the right call — the full line is ~700 characters and saying it twice would have been the thing that taught the operator to skip it.The readiness row: right direction, and one real cost. Before, finding the entity working in Developer Tools contradicted the row, which is what sent the operator to conclude the diagnostics were broken. Now it confirms the second named branch. That is a genuine repair of the failure mode.
But the row is shared, and
checks()renders grid power and battery SoC through the same line. I drove a world where those entities are genuinely absent:There is no freshness gate on the grid or SoC signals. For those two rows the second cause is not a live possibility, so a genuinely missing meter now gets 300 characters of hedge and a "confirm before replacing" instruction in front of a diagnosis that was simply true before. The card names this in §7 and calls it the price of not branching per row, which I think is the right trade — a per-row branch would put "why is this None" in two places, and the battery row is the one that actually fired on a live site. But it is a cost, it is paid on 2 of 3 rows, and §7 is where it is recorded rather than anywhere an operator will see it.
The one wording point I would raise. The row's fastest discriminator — "is the STALE_READING row on the page?" — sits in a parenthetical ("and names itself in a row of its own"), while the slowest one, "Confirm the entity in Developer Tools", is the closing imperative. An operator reads the last instruction and acts on it. Part 11d correctly proves the discriminator row is present in REFUSED and absent in ABSENT — it is selective, it works, it just is not where the eye lands. Promoting it to the final clause would cost nothing and would send the operator to the row that answers the question instead of to the tool that only rules out one branch.
None of this blocks: it is a wording preference on a surface that is strictly better than what it replaces, and the behaviour is identical either way.
7. Scope: 923 insertions for three sentences is proportionate. The two omissions have reasons that are TRUE, which I checked rather than accepted
tools/safety56_behaviour_trace.py(300 lines) earns its place, and comment 1 is the argument. An AST comparison proves no code changed; it cannot prove that no consumer of the changed strings changed behaviour. The trace tool is what closes that, and it is the only artifact in the card that caught a zero-byte behaviour change (MOVE_W250→240) on three independent keys. Its--comparerefusal is also reusable: any future text-only edit to these three surfaces now has a ready-made gate, and the refusal means that gate cannot be passed by comparing a tree with itself.The 181-line DECISIONS file earns its place too, mostly on §7. An honest gaps list — "it does not grade English", "the grid and SoC rows have no gate so the second cause is a hypothesis for them", "it does not show the episode was a BMS refusal beyond doubt" — is worth more than the rest of the file, and it is where I found the cost in comment 6. That is MUTATION.md habit 13 working as intended.
Both omissions verified rather than taken on trust (habit 22 — a stated reason is a claim).
CHANGELOG.mduntouched. The stated reason is thattest_version_gate.pypart 11b would go red on an## Unreleasedsection. I read it. It genuinely asserts_top_name.lower() != "unreleased"and_top_name == shipped_version(HERE), againstconfig.yaml'sversion: "0.10.1"and a newest heading of## 0.10.1. The reason is true, and a bump is correctly PKG's call.MUTATION.mduntouched. Both instrument defects are recorded in the DECISIONS file §5–§6 in habit language, and concurrent cards are editing that file. Reasonable. Note for the record that the M9 defect is a fourth instance of the FCST-06 "red with no name attached" family and the M11 defect is a fresh instance of the vacuous-quantifier family — whoever curates habit 24 should see both.⚠️ One follow-up for PKG, not a defect in this card.
config.yamlstays at0.10.1and the## 0.10.1changelog section says, under WHAT YOU WILL SEE, that the diagnosis half "still says 'is not following its own command' ... deliberately untouched here". That stays true of 0.10.1 the released artifact — the PR's claim is accurate as worded — but it will be false ofrelease/1.0HEAD the moment this merges. That is a structural consequence of the version gate forbidding an## Unreleasedsection, not of this card, and every card merged between bumps has it. It just needs to be picked up when the version next moves.8. CI-10: this green is this pull request's own. Traced to the payload, not taken from the rollup
Measured on PR #102 today, a
successonchecks / unit (pull_request)was produced by a run whose payload carriedPRNUM: 111. Statuses here are keyed by head SHA with nothing binding them to the PR that produced them, and atrequired_approvals: 0the context is the only gate. So I traced it.The status arrived pending at 21:03:43 and only went
successat 21:11:44 — I watched it flip, which already rules out CI-9's launderedskipped(those arrive green withtarget_url: "", no run and no job).GET /actions/runs/823:GET /actions/runs/823/jobs— job 1948, runnergoodwe-rig(id 13), conclusionsuccess, and the test step ran 19:03:50 → 19:11:44 = 7 m 54 s. A real execution on a real runner.And the job log carries the payload itself:
PR 112, base
release/1.0, and both SHAs match this pull request exactly (239e120is the PR's base SHA,8a6acd9its head). It graded the merge, not the branch. It ran all 30 test files, every onePASS, on python 3.14.7 / aiohttp 3.13.5.Independent confirmation on my own hardware: I ran
checks.yml's exact command against a purged:roarchive of the head commit ingoodwe-addon-checks:1— 30/30 PASS, SUITE_EXIT=0, timings within a second or two of CI's throughout. So the green is not only this PR's own, it is reproducible off the forge entirely.9. Part 11 is sound, and §7's "it does not grade English" needs a size next to it — I measured one: +34 B
Part 11 is real work and mostly measures what it says. Confirmed by driving it: 10h is inverted, not weakened — it is not a bare
not in, it also positively pins"did not move under a command that did", so it cannot pass on a tree where the line is blank or never fires; it is one of M10's five reds._s56_row()returns the named absence and 113 of 113 checks stay graded under the zero-byte transposition. 11d's discriminator is genuinely selective. 11a is the strongest new part — its assertions are against literals (min == 260.0,max == 1060.0,_S56_SOC_HELD == [11.0],_HOLD_W == -3.0) over values derived from the committed capture, which is what makes this card a claim about the world rather than about strings.On 11b's "40 of 40 differ" preconditions. They compare two literal lists defined four lines apart, so no mutation in
app/can redden them. That is not a defect — a precondition's job under habit 1 is to guard the fixture, and it does: I set_S56_TRUE_REFUSED = list(_S56_CMD)(the card's M7) and got 2 named reds, printingtheir ground truth differs on 0 of 40 reads— habit 3, it prints what it saw. Worth being clear in one's head that 11b's load-bearing assertion is the one after it (samestale, samebatt is None, sameinputs_ok,_r56_dead == _r56_ref), which is a real behavioural measurement.⚠️ The finding that matters. §7 says "It does not grade English. A sentence that named both explanations and then said something useless about them would pass." That is honest and it is the right gap to declare — but habit 13 asks for the gap WITH A SIZE, and this one has none. So I measured it.
I appended a fresh, differently-worded accusation to the
_grade()sentence:+34 B.
test_freshness.pyexit 0, 113 checks graded, 0 named reds. The whole 30-file suite stays green.The
not any(...)check grades the absence of three specific literals, so a byte-identical revert is caught on every surface (M1/M2/M3/M10, all red by name) — its printed claim, "goes red if any of the three sentences is reverted", is accurate and not overstated. What it cannot reach is the accusation re-added in new words, and no substring predicate ever could; you cannot grade "this sentence blames nobody" byin. That is a limit of the method, not a mistake in this implementation.Why it is worth writing down anyway: the sentence this card exists to fix was itself a re-wording, not a revert. The next one will be too. A reader of §7's single line would not guess that re-introducing a full accusation costs +34 bytes and leaves the suite entirely green — and that number is the difference between a declared gap and a gap somebody actually defends against. I would put the measurement into §7 rather than build anything new.
Not blocking: behaviour is unchanged, the gap is declared, and the revert case — the one a careless edit actually produces — is caught by name on all three sites.
10. Pre-existing, not yours — but an FCST-06 abort sits six lines above the docstring written to prevent it
_s56_row()'s docstring is the best thing in this card's test code:At
test_freshness.py:1598-1599, immediately above the part 10h check this PR edits, unchanged fromrelease/1.0:I renamed the tag on that one log call —
SAFETY-37:→SAFETY-38:, a zero-byte edit:The entirety of part 11 — this card's whole grading surface — goes ungraded, and the red carries no name. CI would report
FAIL test_freshness.pywith a traceback, which is precisely the shape MUTATION.md says gets retried rather than read.This is pre-existing and I am not asking this card to fix it. It is identical in
release/1.0, it belongs to SAFETY-57's block, and widening scope on a text-only card to chase it would be the wrong call. Two reasons to record it:_s56_row()'s named-absence pattern is the fix and it is already written — applying it to_stale57is a two-line change for whoever next opens this file.Worth a line in whatever card next touches
test_freshness.py's part 10.Verdict: sound. Merging.
Behaviour did not move, and that was the one thing that could block. Established twice over, independently of the card's own claims: the trace tool reproduces
6030175ed2ab…on both trees with every stated count exact, and an AST comparison with string constants normalised showsapp/freshness.pyandapp/main.pyare structurally identical across the whole of both files. Nothing inapp/parses any of the three strings, which closes the one gap AST-identity leaves. The tool's controls are real — I drove both refusals, and theMOVE_Wone is a zero-byte edit caught on three keys.Everything the card asserts, I could reproduce. Five mutations re-applied on fresh purged trees, all five matching the claimed named-red counts exactly, three of them zero-byte. 113 checks stay graded under the transposition that used to abort. 30/30 suite green on my own hardware under
checks.yml's exact command. The corrections to the brief are all right: three call sites not two,main.py:1793/main.py:4667/freshness.py:571,admin/goodwe-addon, and 11 % — which I measured off the capture, along with+260/+660/+1060discharge-only, the-400 Wcharge answered in 3 s, and 790 s to the second.The green is this PR's own —
PRNUM: 112,BASEREF: release/1.0, both SHAs matching,GRADED: refs/pull/112/head merged into release/1.0, a real 7 m 54 s execution ongoodwe-rig. Traced to the run payload, not taken from the rollup.Four things recorded, none blocking: the grid/SoC rows pay for the shared wording (#6), the accusation check's declared "does not grade English" gap is +34 B wide and that size belongs in §7 (#9), a pre-existing zero-byte FCST-06 abort sits six lines above the docstring written to prevent it (#10), and
DOCS.md's triage table is a fourth operator-facing surface for a later card (#5).The card does the thing it set out to do: three sentences that stop asserting a fact the gate cannot know, on a gate whose conjunction is correct and untouched. The operator who is woken at 03:00 is now told what was observed and what it does not prove, instead of being sent to replace working hardware.