# SAFETY-107 - a refusal held under another writer is learned once **Status: for review.** Tests: `test_safety107.py`. One inverter only; nothing changes at N>1 (SAFETY-73's unit refusal has no "another writer" end). **The defect** (MAINT-13 F5, MAINT-14 "seen again"). A full pack under a maintenance charge in a cheap window takes no charge. At N=1 the action log then showed "BATTERY REFUSES asked -2500 W ..." and "BATTERY REFUSES ENDED: another writer" every cycle: 548 + 548 lines in 15 min on the MAINT-13 rig head, 652 + 651 in 12 min on its base, 33 + 32 in 60 s in MAINT-14's c2b. The 40-line log flooded and the hint under the buttons changed every second, so the MAINT-13 notice was gone at once. **Cause** (`main.py`, the SAFETY-52 block before `compute()`). The end tested `self.target != self.loop_w`: the wire differs from the loop's ask. A held refusal makes the loop ask 0 W, so any claim that binds (the maintenance charge's `hi`, the peak guard's `lo`, a trade's SET) differs from it on every cycle. The refusal was ended against the same writer it had been learned against. `sat_count` is not reset by that end, so `compute()` learned the same refusal again in the same cycle. Nothing about the battery or the wire had changed; only the log and the hint did. **Other writers.** The same flap comes from any writer that holds the wire while the loop is refused, as long as the evidence lasts: a trade (a SET at P_ARBITRAGE) into a full pack, and the peak guard over an empty pack: 335 refusal lines in 15 min each at a 5 s meter (`test_safety107.py` part 2, run on release/1.0). A maintenance drain or hold does not flap: its SET resets `sat_count` each cycle. **The rule** (`_refusal_update()`, `rejudged`). When another writer held the wire, the held refusal is judged afresh: `compute()` gets `refused=0` and no probe, exactly as before the change. Then: 1. it finds the same refusal against that writer's ask: the same episode continues, with no line and no hint. The probe clock restarts, as it did on the old end and start: a probe is the loop's ask, and it is not on the wire while another writer is; 2. it does not: the refusal ends, logged "another writer", or "the battery gives power" when the reading shows it (it was "another writer" before); 3. it finds the other direction: end, then a new start, as before. In practice 2 comes first: SAFETY-94's evidence restarts when the ask changes sign, so a writer that turns round ends the refusal at once and the other way is learned ~12 cycles later (`test_safety107.py` part 3e). **Why this rule.** The end now means what SAFETY-52 meant by it: the refusal no longer describes what is on the wire, judged by the same test that learns it. `compute()` sees the same inputs as before and the state after each cycle is the same (`batt_refused`, `batt_refused_at`), so every command is unchanged. Keeping the held refusal into `compute()` instead (mutation M5) would also stop the lines, but it holds the loop at 0 W where the old law let it ask past a writer's limit, which changes commands; it also removes SAFETY-52's owner-decided "another writer" exit. **Evidence.** - Differential trace (`goodwe/scratch/SAFETY-107/probe/trace.py`, not in this repo; the real Controller in the CI image, release/1.0 bf3ac79 against this tree): 300 seeds, N=1 (153) and N=2 (147), 1/2/5 s meter, full/empty/dead/healthy battery windows, a stepping house, maintenance charge with the cheap window opening and closing, P1 and the peak guard, trade windows, stop/start; 427 680 cycles, 294 seeds with a refusal held. Every command, site target, integrator, `sat_count`, refusal state and reason is identical. Refusal lines: 100 748 before, 2 188 after. - The card's case: 1 line in 15 min at N=1 and N=2, 1/2/5 s meter, house importing or exporting (release/1.0: 335-1 753 at N=1; N=2 was already 1). **Mutations** (`tools/mutation_run.sh` on purged copies; reds in test_safety107 / test_refusal / test_refusal_units; trace = seeds of 300 whose commands differ from release/1.0): | | mutation | reds | trace | |---|---|---|---| | M1 | `now == rejudged` -> `False` (every such cycle ends: the old flap) | 26 / 0 / 0 | 0 | | M2 | `rejudged` always 0 (another writer never ends it) | 5 / 1 / 2 | 29 | | M3 | the probe clock is not restarted on a continued episode | 1 / 0 / 0 | 2 | | M4 | a probe may open while rejudged | 0 / 0 / 0 | 0 | | M5 | the held refusal goes into `compute()` while rejudged | 2 / 1 / 2 | 27 | | M6 | an end while rejudged always says "another writer" | 1 / 0 / 0 | 0 | M4 is equivalent: a probe matters only if `compute()` engages in that call, which needs `SETTLE_CYCLES` saturated against the writer, and each of those cycles restarted the clock. The line stays because it states the rule. **Cost.** None in power. A takeover by a different writer while the battery still refuses is logged as an end and, ~12 cycles later, a new start against that writer (3 lines for the episode, `test_safety107.py` part 3a). **Not covered.** - A perfectly flat reading under a writer. The stale-reading gate trips, stops the site and seeds the refusal (SAFETY-52 decision 3). After the stop the writer's ask returns fresh, `compute()` has no saturation history against it, so the refusal ends at the first cycle and the next trip seeds it again: 2 lines per gate cycle (29 in 15 min, house exporting), the same as release/1.0. The gate cycle itself (0 W, then the writer's ask) is the cause; stopping it changes commands. None of the rig runs showed it: real readings move. - At N=1 a refusal shapes only the loop. A writer above it still asks the battery; at N>1 SAFETY-73's P_SAFETY claim binds every writer.