SAFETY-38 round 4: the enumeration covered one sink, and it was not the one this codebase uses
checks / unit (pull_request) Successful in 5m12s

The scanner's two censuses between them scored ONE syntactic sink and called it
the family. Every one of census B's 16 members was a sentence that is APPENDED,
ASSIGNED to a `reason`-shaped name, or passed as a LOG ARGUMENT. Not one was
RETURNED - and census A's return bucket only ever held a callee formatting its
own PARAMETERS, so a method formatting its own ATTRIBUTES and handing the string
back was invisible to both. That is the whole describe() / explain() /
reason-builder family, which is where this repository actually keeps its
operator sentences. Worse than the gap: it was in NONE of the five blind spots
the tool printed about itself.

THE SCANNER, WIDENED - and it rediscovered both members without being told:

  * CENSUS C, the return sink, resolved through the returning method's CALL
    SITES so a returned KEY (store.unit_key, mqtt.unit_state_key - MAINT-05's
    card) is not mistaken for a returned SENTENCE. 53 returned pairs, 39
    reaching an operator, 16 of those sharing a format spec.
  * A sentence handed to a constructor as `reason=` or placed under a "text"
    dict key is a MESSAGE. That one line of the sink walker was hiding SIX more
    caller-built members, census B 16 -> 22.
  * Every census B and C row now prints whether the two interpolations share a
    FORMAT SPEC - the difference between a transposition that is silent and one
    that merely looks broken.
  * The blind-spot list is now TWO lists: what it scans (printed from the
    constants, so they cannot drift) and WHAT IT DOES NOT SCAN AT ALL, largest
    hole first - `%s`-style lazy logging, MEASURED at 31 calls across 8 modules
    and each one listed, not asserted.

CLOSED, both zero-byte survivors of a green 21-file suite:

  * clamp.py:253 ClampResult.describe(), requested_w <-> applied_w. No
    test_*.py mentioned describe() at all. It is the SS15 action-log row
    (main.py:1366) AND the ClampWatchdog notification reason (main.py:2452) -
    the alarm SAFETY-40 and SAFETY-41 are about. Under the swap the guard
    reports a setpoint clamped UPWARD, to +9999 W by a bound whose rating the
    same sentence gives as 2000 W. Pinned on the ORDERED PAIR on the contiguous
    literal at BOTH consumers, operands read off ctl.target and
    hass.writes[-1] - what the loop asked and what the device was sent - not
    off the ClampResult the swap lives in. 4 checks red.
  * peakguard.py:493 PeakDecision.explain(), projected_w <-> ceiling_w. "peak
    guard idle" was in no test_*.py. The row then reads "projects 5013 W under
    a 2300 W ceiling" - 2713 W OVER - on a row whose own words say idle and
    whose own flag says ok. Pinned on the decision and on the shipped status
    row. 3 checks red.
  * test_arbiter.py's scope-violation check, whose CLAIM was wider than its
    ASSERTION. Measured this round rather than left at source level: 0 bytes,
    21/21 green before, red now. 2 checks.

DISCLOSED, NOT CLOSED - 11 measured zero-byte survivors, each with its file
fingerprint and its 21/21 verdict, in the decision doc SS7e-SS7g. Three in
allocator.py's UnitPlan.explain(), three across arbiter.py's two explain()s,
two in tariff.py, two in arbitrage.py's trade reasons, one in main.py's
SoC-floor notification. An enumerated list with honest gaps beats a fifth round
finding a fifth instance.

CORRECTIONS THIS ROUND OWES:

  * The idle sentence does NOT reach main.py:3299/3302 - those are the
    floor-limited branch, where explain() returns self.reason before the idle
    literal is built. It reaches main.py:3313 and, on the release edge, 1951.
    1951 is disclosed unpinned.
  * SS4's "Every one of the ten is red" sat under a twelve-row table: it is the
    ten site_standing entries.
  * SS6b said "weaker evidence than A1-A3"; A4 was measured, so SS7d's
    "A1-A4 and B1" is the right list and SS6b now says it.
  * AND THE PROBE THAT GRADES THESE MUTATIONS WAS THE DEFECT, ONCE. Its first
    cut numbered the operands BY ORDER OF APPEARANCE, so after a transposition
    the other leaf took the same number and all thirteen mutant renders came
    back byte-identical - which reads exactly like "the edit is inert". Keying
    the value to the NAME fixed it. Habit 4, in the measuring instrument:
    MUTATION.md habit 16 is the new entry, and the count there is corrected to
    eleven rather than inflated.

14 mutations measured: fresh full-repo copy each, mounted :ro, bytecode purged
and the purge COUNT printed, the edit re-read from disk, length and sha256
printed on both sides, and every one required to change the EMITTED SENTENCE
and not merely the bytes. 12 survived the pre-round suite, 1 was already
caught, 3 are caught now. Full 21-file suite green on the final tree.

NOT ONE BYTE OF app/ MOVES. This card changes coverage only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
Claude Opus 5
2026-09-05 22:38:40 +02:00
parent 357cb4f07f
commit e2efc765af
7 changed files with 901 additions and 78 deletions
@@ -234,7 +234,7 @@ fingerprint, and verifies no cache appears during the run). The mutated line was
| **M11** | **argument SWAP** at the SITE call site: `evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)` (main.py:1620) | **0** | **was a SURVIVOR in round 1 — now RED**, see §4a |
| **M12** | **argument SWAP** at the PER-UNIT call site: `evaluate(stood, against, …)` → `evaluate(against, stood, …)` (**main.py:1560**) | **0** | **was a SURVIVOR in round 2 — now RED**, see §4c |
**Every one of the ten is red in `test_watchdog.py`.** Re-measured in round 2
**Every one of the ten `site_standing` entries is red in `test_watchdog.py`.** Re-measured in round 2
on `release/1.0` @ `86eaef2` (where `app/main.py` is 234 542 bytes, 2 022 more
than the `f72c48d` the round-1 figures were taken on): all ten still red, at the
byte deltas in the table, each on a fresh copy with the edit re-read from disk.
@@ -584,13 +584,73 @@ disk, byte delta printed):**
this one.
* **Twelve census-B sites** (§7c, B2–B12) determined unpinned at the **source
level** — the distinguishing literal appears in no `test_*.py` — and **not**
mutated. Weaker evidence than A1–A3, and written down as such.
mutated. Weaker evidence than A1–A4 and B1, and written down as such
— §7d says the same thing with the same list.
* **B6**, SAFETY-31's `circ_reason`, is the same shape as the defect this card
closed for SAFETY-02/30 and is *also* persisted and re-read after a restart.
§6a already declines to touch `circ_wd`; §7d says what that costs.
* Everything §6a lists stays open: `circ_wd`'s own thresholds, the eight other
`CLAIM_SCOPE` entries, `actuator_of()`'s body, and the `n_units > 1` guard.
### 6c. Round 4: what was closed, and what is still open
**The blocker round 3 shipped.** The enumeration read as complete and covered
**one syntactic sink**. Every one of census B's 16 members was a sentence that
is **appended, assigned, or passed as a log argument** (`problems.append`,
`out.append({"text": …})`, `self.reason = …`, `_LOG.info`). **Not one was a
sentence that is RETURNED.** Census A has a return bucket, but only for
symmetric **parameters**, so a method formatting its **own attributes** and
handing the string back was invisible to both — and `describe()` / `explain()` /
the reason-builders are exactly where this codebase keeps its operator
sentences. Worse than the gap: it was in **none of the five blind spots the tool
prints about itself**. A tool that asserts a completeness it does not have is
the recurring defect wearing a new hat.
**Closed in round 4:**
1. **`clamp.py:253` `ClampResult.describe()`**, pair `requested_w` /
`applied_w` — §7e C6. A survivor of the round-3 tree at **21/21 green**,
**0 bytes**. It is the §15 action-log row at `main.py:1366` **and** the
ClampWatchdog notification reason at `main.py:2452`; **no `test_*.py`
mentioned `describe()` at all.** Pinned on the ordered pair, on the
contiguous literal, at **both** consumers, with a precondition that the two
operands render differently — `test_clamp.py` part 6d. **4 checks red** under
the transposition.
2. **`peakguard.py:493` `PeakDecision.explain()`**, pair `projected_w` /
`ceiling_w` — §7e C7. Same measurement: **0 bytes, 21/21 green**; the string
`"peak guard idle"` was in no `test_*.py`. Pinned on the decision and on the
shipped status row at `main.py:3313` — `test_peakguard.py` part 13. **3
checks red.**
3. **`arbiter.py:264` (B12)** — the check whose **claim was wider than its
assertion**, carried forward from §7d. Measured this round rather than left
at source level: **0 bytes, 21/21 green** before, **`test_arbiter.py` red**
after. **2 checks red.**
4. **The tool's sinks are now stated rather than implied.** It prints what it
scans as a MESSAGE, that it now scans the RETURN sink through the returning
method's call sites, and — the part that was missing — **an explicit list of
what it does not scan at all**, largest hole first.
**⚠️ The correction round 4 owes the ticket.** The brief placed
`PeakDecision.explain()`'s idle sentence at `main.py:3299/3302`. Those two lines
are the **floor-limited** branch, where `explain()` returns `self.reason` before
the idle literal is ever built. The idle sentence reaches the page at
**`main.py:3313`** and the action log at **`main.py:1951`** (edge-logged, so it
is the RELEASE edge that writes it). The pin is on 3313; 1951 is disclosed
below, unpinned.
**Open, named, and measured — see §7f for the ledger:**
* **Nine further zero-byte survivors** found by the widened scan, each measured
at 21/21 green on this tree: three in `allocator.py`'s `UnitPlan.explain()`,
three in `arbiter.py`'s two `explain()`s, two in `tariff.py`, and one each in
`arbitrage.py`'s two decision reasons and `main.py`'s SoC-floor notification.
None is a control decision; every one is a sentence an operator reads.
* **The action-log surface of the peak-guard idle line** (`main.py:1951`) is
not pinned. The status row (3313) is, and both come off the same `explain()`,
so the *sentence* is closed and the *second call site* is not — which is
precisely the distinction §4c was written about, recorded rather than assumed.
* Everything §6a and §6b list stays open.
## 7. The symmetric-predicate family — every call whose argument order is load-bearing for a MESSAGE
**Why this section exists, and it is the most useful thing in the card.** Round 2
@@ -664,8 +724,7 @@ symmetry test has nothing to test. The scan pairs interpolated expressions that
are **one leaf apart** — `inv.dev_min` / `inv.dev_max`, `cfg.min_w` /
`cfg.hold_s` — which is exactly what a transposition swaps, and it keeps only
sentences whose sink is a **message**: an f-string that becomes a URL or a store
key is data, and a swap there is an ordinary behavioural change. **16
candidates**, every one of them a **zero-byte** edit:
key is data, and a swap there is an ordinary behavioural change. **16 candidates** in round 3, every one of them a **zero-byte** edit — and **22** after round 4 widened the sink classifier (B17–B22 below):
| # | site | pair | reaches an operator as | pinned? |
|---|---|---|---|---|
@@ -678,11 +737,22 @@ candidates**, every one of them a **zero-byte** edit:
| **B7** | `main.py:2454` | `self.site_max_w` / `self.target` | the clamp action-log line | **NO** — `was clamped to` is in no `test_*.py`. |
| **B8** | `main.py:3386` | `clamp_wd.engagements` / `.episodes` | status page, `engaged N time(s) in M episode(s)` | **NO** — `time(s) in` is in no `test_*.py`. |
| **B9–B11** | `main.py:3169` (three pairs) | `p1.net_w` / `.samples` / `.parse_errors` | status page, the P1 row | **NO** — `P1 meter (` is in no `test_*.py`. |
| **B12** | `arbiter.py:264` | `c.source` / `c.actuator` | `scope_errors`, the arbiter's mis-routing refusal | **NO, and this one is instructive.** `test_arbiter.py:452` says it *"names the source and the actuator it does not belong on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Under the swap the sentence becomes `"inverter-b: a SITE claim filed on unit 'peak'"` — and `"peak" in …` is **still true**. A claim wider than its assertion, which is habit 14's own shape. |
| **B12** | `arbiter.py:264` | `c.source` / `c.actuator` | `scope_errors`, the arbiter's mis-routing refusal | **ROUND 4: MEASURED, THEN CLOSED.** Round 3 left it at source level; the transposition is **0 bytes**, `arbiter.py` `fdb4ed0520eeb721` → `9111f627ef639ecc`, and it was **21/21 green**. Now red in `test_arbiter.py` on 2 checks. Round 3's reasoning was right: `test_arbiter.py:452` says it *"names the source and the actuator it does not belong on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Under the swap the sentence becomes `"inverter-b: a SITE claim filed on unit 'peak'"` — and `"peak" in …` is **still true**. A claim wider than its assertion, which is habit 14's own shape. |
| **B13** | `validate.py:723` | `meter_phases` / `meter_max_age_s` | the startup refusal | **NO** for the ordered pair. |
| **B14** | `validate.py:1211` | `arbitrage_soc_floor` / `arbitrage_soc_ceiling` | the startup refusal | **NO** for the ordered pair. |
| **B15** | `watchdog.py:667` | `cfg.min_w` / `cfg.hold_s` | the `SIGN_DISAGREEMENT` sentence's `(limits: both over 300 W, held over 180 s)` clause | **YES**, and it is worth naming because it shows the habit works. `test_watchdog.py`'s SAFETY-30 section asserts the site instance's **whole sentence with `==` against a literal**, the `(limits: …)` clause included. Full-string equality is habit 14 taken to its limit. |
| B16 | `main.py:237` | `i.name` / `i.max_w` | the roster log line | **not real** — a `str` beside a `:.0f` format. The same over-report as A5–A6: a mixed-type transposition raises rather than lying. |
| **B17** | `arbitrage.py:377` | `v.spread_eur_kwh` / `v.required_spread_eur_kwh` | the DISCHARGE decision's `reason` | **ROUND 4, NEW, and MEASURED: 0 bytes, 21/21 green.** Not visible to round 3 at all — the sentence is handed to a constructor as `reason=`, and the sink walker scored only *assignments*. The row then reads *"0.0400 above the cheapest and clear of the 0.1200 this cycle had to cover"* with the two swapped: a trade justified by a spread it did not clear. |
| **B18** | `arbitrage.py:393` | same pair, CHARGE branch | the CHARGE decision's `reason` | **ROUND 4, NEW, MEASURED: 0 bytes, 21/21 green.** |
| **B19** | `main.py:2652` | `g.reason` / `g.soc_floor_reason` | the `goodwe_peak_soc_floor` notification body | **ROUND 4, NEW, MEASURED: 0 bytes, 21/21 green.** The alarm then opens with the *reason the floor bound* and closes with the *guard's own conclusion*, i.e. both halves of a two-part explanation in the wrong order, in the one message SAFETY-24/25 exist to send. |
| B20–B22 | `main.py:2558`, `main.py:2652` (two more pairs) | `g.reason` / `g.ceiling_w`, `g.ceiling_w` / `g.soc_floor_reason` | the same two notifications | **new in round 4, NOT measured** — mixed types (`str` beside `:.0f`), the same over-report class as B16. |
**⚠️ Six of these twenty-two were invisible to round 3, and the reason is one
line of the sink classifier.** It scored a sentence a MESSAGE when it was
appended, logged, raised, or assigned to a `reason`-shaped **name**. This
codebase mostly builds operator sentences as `Decision(…, reason=f"…")` and
`out.append({"text": f"…"})` — a **keyword argument** and a **dict key**, which
that walker stepped straight past. Both are message sinks now.
### 7d. What this enumeration does NOT close, with sizes
@@ -723,3 +793,123 @@ candidates**, every one of them a **zero-byte** edit:
non-f-string payloads, anything outside `app/`, and the one way it
**over-reports** — it erases the sentence, so a swap that would make the
formatting itself raise is scored as changing no decision (A5–A6 above).
### 7e. Census C — the sentence an object returns about itself (round 4)
**Why there is a third census.** A and B between them covered **one syntactic
sink**. Everything either of them reported was a sentence that is appended,
assigned, or logged; **none was returned**. Census A's RETURN bucket only ever
held a callee formatting its own **parameters**, so a method formatting its own
**attributes** and handing the string back fell between them — and that is the
whole `describe()` / `explain()` / reason-builder family, which is where the
operator sentences in this codebase actually live.
**The test** is census B's — one f-string, two interpolations **one leaf apart**
— run over the RETURN sink, plus one thing B never needed: **a returned string
is not yet a message.** `store.unit_key()` and `mqtt.unit_state_key()` return
f-strings too, and swapping there renames a store key and an MQTT topic, which
every ordinary check can see (MAINT-05). So the sink of a returned sentence is
**resolved through its call sites**: it is in this family only when some caller
in `app/` hands it to an operator. That resolution matches on the **bare method
name**, which over-collects — at least four unrelated classes have an
`explain()` — so every verdict is printed **with the sites that produced it**,
and a MESSAGE verdict is a prompt to read them, not a proof.
The scan reports **53 returned pairs, 39 reaching an operator, 16 of those
sharing a format spec** (two values rendered alike, so a transposition is silent
rather than visibly broken). It **rediscovered both members this round was sent
to fix without being told where to look**, which is the only reason to trust the
rest of the list.
| # | site | pair | reaches an operator as | Δ bytes | verdict |
|---|---|---|---|---|---|
| **C6** | `clamp.py:253` `ClampResult.describe()` | `requested_w` / `applied_w` | §15 action log (`main.py:1366`) **and** the ClampWatchdog notification reason (`main.py:2452`) | **0** | **was a SURVIVOR at 21/21 — now RED**, `test_clamp.py` part 6d, 4 checks |
| **C7** | `peakguard.py:493` `PeakDecision.explain()` | `projected_w` / `ceiling_w` | status row `main.py:3313`; action log `main.py:1951` on the release edge | **0** | **was a SURVIVOR at 21/21 — now RED**, `test_peakguard.py` part 13, 3 checks |
| C1 | `allocator.py:154` `UnitPlan.explain()` | `self.span` / `self.why` | the arbiter/allocator log line | **0** | **SURVIVOR, 21/21 green** |
| C2 | `allocator.py:160` | `self.span` / `self.why` | the ENVELOPE VIOLATED line | **0** | **SURVIVOR, 21/21 green** |
| C3 | `allocator.py:162` | `self.span` / `self.why` | the ordinary in-envelope line | **0** | **RED** — `test_alloc.py` already asserts that whole literal. The one member of this census the suite could already see, and it says the habit works. |
| C4a | `arbiter.py:344` `UnitBounds.explain()` | `self.lo` / `self.hi` | `CONTRADICTION within the unit (device lo=… > peak hi=…)` | **0** | **SURVIVOR, 21/21 green** |
| C4b | `arbiter.py:344` | `self.lo_src` / `self.hi_src` | the same sentence, the two SOURCE names | **0** | **SURVIVOR, 21/21 green** |
| C5 | `arbiter.py:392` `Resolution.explain()` | `self.winner` / `self.bound_by` | `main.py:2236`'s `ARBITER:` action-log row and `self.reason` at `main.py:2329` | **0** | **SURVIVOR, 21/21 green** — the winner and the limiter swap names, on the line §10.1 exists to make legible |
| C8 | `tariff.py:279` `PeakReading.explain()` | `self.month` / `self.detail` | the PEAK UNREADABLE row | **0** | **SURVIVOR, 21/21 green** |
| C9 | `tariff.py:528` `Projection.explain()` | `self.projected_w` / `self.elapsed_s` | the projection line | **0** | **SURVIVOR, 21/21 green** — watts printed as seconds and seconds as watts, both `:.0f` |
**Not in the family, and the scan says why rather than dropping them silently:**
`inverter.py:338` `__repr__()` and `static_tariff.py:378` `words` are reported
**UNRESOLVED — no call site inside `app/`** (a `__repr__` is called by the
interpreter, not by a name the scan can match); `price_forecast.py:421/428` and
`tariff.py:214` `month_key()` resolve to **OTHER** — `month_key` is a **store
key**, which is MAINT-05's case and a swap there is behaviour every check sees.
### 7f. Round 4's mutation ledger
Same protocol as §4: a fresh full-repo copy per mutation, `:ro`, bytecode
purged **and the purge count printed** before the edit, the edited file
**re-read from disk**, both sides' length and sha256 printed, and the full
**21-file** suite via `tools/mutation_run.sh`. Every row is a pure
transposition, so **every Δ is 0 bytes** — verified, not assumed: `len(old) ==
len(new)` is asserted before the write and `len(after) == len(before)` after it.
**⚠️ And every row was also required to change the ANSWER.** A byte delta says
the file moved; it does not say the sentence did. Each mutated tree is rendered
through a scratch probe that evaluates the shipped f-string with **distinct
operands** and diffs it against the same render of the clean tree. **The first
cut of that probe numbered the operands by ORDER OF APPEARANCE** — so after a
transposition the other leaf took the same number and all thirteen diffs came
back **empty**, which reads exactly like "the edit is inert". Keying the value
to the **name** fixed it. That is the same defect as everything else in this
card, in the instrument this time: a measurement comparing the swap against
itself.
| mutation | file, before → after (sha256, 16) | Δ | verdict |
|---|---|---|---|
| C1 `allocator.py:154` span↔why | `10c1b26ed0099654` → `ac0c174de1ec28ad` | 0 | **SURVIVED 21/21** |
| C2 `allocator.py:160` span↔why | `10c1b26ed0099654` → `9b10730f4659befd` | 0 | **SURVIVED 21/21** |
| C3 `allocator.py:162` span↔why | `10c1b26ed0099654` → `07097bf5da31f0c6` | 0 | **RED** — `test_alloc.py` |
| C4a `arbiter.py:344` lo↔hi | `fdb4ed0520eeb721` → `009820de7ef0eff0` | 0 | **SURVIVED 21/21** |
| C4b `arbiter.py:344` lo_src↔hi_src | `fdb4ed0520eeb721` → `04cb8cf69dfc7432` | 0 | **SURVIVED 21/21** |
| C5 `arbiter.py:392` winner↔bound_by | `fdb4ed0520eeb721` → `9238c3db920da8ad` | 0 | **SURVIVED 21/21** |
| **C6** `clamp.py:253` requested↔applied | `0153133741497fa5` → `47e4e371043e3201` | 0 | **SURVIVED 21/21 before — RED after**, `test_clamp.py`, 4 checks |
| **C7** `peakguard.py:493` projected↔ceiling | `d95df61a51716c26` → `2d2ee3d0bcd51251` | 0 | **SURVIVED 21/21 before — RED after**, `test_peakguard.py`, 3 checks |
| C8 `tariff.py:279` month↔detail | `82790523dd5227c9` → `4b2147ca17a28425` | 0 | **SURVIVED 21/21** |
| C9 `tariff.py:528` projected↔elapsed | `82790523dd5227c9` → `517e9148d26a243a` | 0 | **SURVIVED 21/21** |
| B17 `arbitrage.py:377` spread↔required | `67fbf949b2938349` → `cddd8045bdeb5e29` | 0 | **SURVIVED 21/21** |
| B18 `arbitrage.py:393` spread↔required | `67fbf949b2938349` → `04a4f3ad3d255de1` | 0 | **SURVIVED 21/21** |
| B19 `main.py:2652` reason↔soc_floor_reason | `61aa413fb890433e` → `bbcbb28f476a98d0` | 0 | **SURVIVED 21/21** |
| **B12** `arbiter.py:264` source↔actuator | `fdb4ed0520eeb721` → `9111f627ef639ecc` | 0 | **SURVIVED 21/21 before — RED after**, `test_arbiter.py`, 2 checks |
**14 measured, 12 survived the full suite before this round's checks, 1 was
already caught, 3 are caught now.** The tree these were taken on is
`SAFETY-38` @ `357cb4f` rebased on `release/1.0` @ `9e42cec`; `app/main.py` is
234 542 bytes.
### 7g. What round 4 does NOT close, with sizes
* **Eleven measured zero-byte survivors are left open** — C1, C2, C4a, C4b, C5,
C8, C9, B17, B18, B19 and the three already open from round 3 (A3, B1, and the
B2–B11/B13–B14 source-level set). Each is a sentence an operator reads and
none is a control decision. They are disclosed with their measurement rather
than pinned because pinning each one wants a fixture in the file that owns the
module — `test_alloc.py`, `test_arbiter.py`, `test_tariff.py`,
`test_arbitrage.py` — and this card has now spent four rounds inside
`test_watchdog.py`'s subject. **An enumerated list with honest gaps beats a
fifth round finding a fifth instance**; what would NOT be honest is a fifth
round finding one that is **not on this list**.
* **`main.py:1951`, the action-log surface of the peak-guard idle line,** is
unpinned. The status row off the same `explain()` is pinned; the second call
site is not, and §4c is the card about exactly that costing a round.
* **The largest remaining hole is not in any census: `%s`-style lazy logging.**
`_LOG.error("… %s … %s", a, b)` puts the two operands in *arguments*, not in
an f-string, so nothing here looks at them and the transposition is the same
zero-byte edit. The tool now says so first in its NOT-SCANNED list, **with
the size measured rather than asserted: 31 such calls across 8 modules in
`app/`, each one listed**.
* **The tool's own blind-spot list was the round-4 blocker and is now two
lists.** "What this scans, and what it does not" enumerates its message sinks
from the constants themselves and then names, in order, what it never looks at:
`%s`-style logging, `.format()`/`%`/`+`/`.join()` sentences, a sentence that
leaves through a dict or dataclass and is rendered elsewhere, `yield`ed
sentences, attributes outside the `reason`-shaped set, and everything outside
`app/`. "What it can see but gets wrong" keeps the five gaps round 3 printed,
with the over-report gap now backed by a **printed format-spec column** so a
reader can tell a silent same-typed transposition from a noisy mixed-type one.