Owner decision 2026-09-30: the peak guard wins over the maintenance HOLD, as it does over the drain (SAFETY-83) and the charge (SAFETY-84). Record: DECISIONS/SAFETY-86-peak-over-hold.md. Tests: test_safety86.py.
The defect (reproduced on 4ad8298 in the test harness)
N=1: during a HOLD, a peak floor above 0 W is refiled at P_MAINTENANCE, the same move as _peak_over_drain(). The battery gives the floor. The Why line reuses "Battery maintenance: 3 051 W, raised for the peak limit". No new strings.
N>=2: the holding battery's pin is raised to the floor minus what the other live batteries may place (their hard hi, or their pin). The others give first and the holding battery gives only the shortfall. Why this split and not the allocator's normal shares: the hold is maintenance, which ranks above the others' modes and use in the owner's order. It is also the same rule the owner set for an Off battery ("gives way only for the shortfall"). So an Off battery (P_MODE, below maintenance) still gives before the holding one (test part 3).
Stops: a site stop (a SET above P_PEAK) decides alone. At N>=2 nothing is raised, and at N=1 the refiled floor stays below P_SAFETY. If another battery is pinned to charge (a SAFETY-73 probe), the pin is not raised, so the site is never split both ways.
After the peak: the hold resumes by itself. The rule runs every cycle, so when the guard lets go the command is 0 W again.
The level dips below the target: the hold finishes anyway. The HOLD does not read the level; it ends on its clock. A top-up would mean going back to CHARGE. CHARGE waits for sun or a cheap window (up to 36 h). Meanwhile the battery keeps the site's one maintenance slot, so the other battery's cycle waits, and then a new full hold starts that the guard can interrupt again. The guard only gives while the quarter is at risk, and the next monthly cycle balances again. Owner may prefer a top-up. That would be a change in maintenance.py's HOLD branch, not in this rule.
Tests (test_safety86.py, real Controller, safety84_identity harness)
N=1, houses 5000/6500/7000: quarters on the aim, the phase stays hold, no alarm. This holds with 5 s spikes too (SAFETY-81 hold). Non-vacuous without the fix.
Under the aim (N=1: 1200, 3000, −1500; N=2: 1500, 3000, 5000): A is at 0 W and every row equals the run without the fix.
N=2, houses 6500/7000: on the aim, B at 2500 W, and A's pin = floor − 2500 on every cycle where A gives. The same holds with modes on and B Off. No refusal, no opposite split, no alarm.
Banked month peak 4000 → aim 3950, at N=1 and N=2.
Lost reading (site safety SET): every battery is at 0 W at N=1 and N=2, and the hold gives again after.
The level falls from 99 % to 70 % during a 30 min hold: HOLD → IDLE, "cycle COMPLETE", never CHARGE, at N=1 and N=2.
Boundaries on claim lists: a floor at, under, or 0.5 W over B's max; B not live, contradicting, refusing, or probing either way; a site vs a unit safety SET; DRAIN or idle not raised; N=1 floor 0 vs 0.5 W vs a ceiling-only claim.
Mutations (fresh copy per mutant, __pycache__ purged, python3 -B)
#
mutation in _peak_over_hold()
test_safety86
first red check
M1
N=1 refile removed
RED
part 1 quarters on the aim
M2
N>=2 pin not raised
RED
part 3 on the aim / shortfall
M3
site-stop check removed
RED
part 7 site safety SET
M4
others not subtracted (A gives the whole floor = allocator-style share)
RED
part 2 rows identical, part 3
M5
live check dropped
RED
part 7 B not live
M6
contradicting unit counted
RED
part 7 B contradicting
M7
others' pin ignored (hard hi used)
RED
part 7 B probing / unit SET
M8
charging-pin guard removed
RED
part 7 B probing to charge
M9
rule also applied to a DRAIN
RED
part 7 "only a HOLD" (test_safety83 red too)
M10
floor of 0 W counts (lo >= 0)
RED
part 7 N=1 floor 0
M11
negative give not dropped (A would charge)
RED
part 2, part 7 floor under B
M12
call site removed
RED
part 1
No survivors. owns_setpoint in the HOLD test is not mutated: a HOLD always owns the setpoint, so that mutant would be equivalent.
Pins and other suites
No digest re-pinned. The SAFETY-78/83/84, OPT-11, peak-margin and other identity suites pass unedited against their existing pins.
test_safety83.py: its last check asserted "HOLD not changed at N=1" (SAFETY-83's "Not changed" list). It now asserts the battery gives, bound by the peak. That change is intentional and is this ticket.
Separate commit 513183a (not SAFETY-86):test_peak_margin.py part 3 read the simulated September's month peak on the wall clock, so from 2026-10-01 it crashed on every tree, 4ad8298 included (I ran it on 4ad8298: same TypeError). It now reads at the runs' clock (BASE). This is test-only and was needed for CI to go green.
Full suite, run CI-style in goodwe-addon-checks:1: 55 of 56 passed. test_named_absence flagged two unguarded reads in test_safety86.py. After fixing those (test only), test_named_absence and test_safety86 pass.
Out of scope, and whether this changes them
SAFETY-87 (the guard grades the emptiest battery): unchanged, and now more visible. At N=2 with B under peak_soc_floor, the guard is floor-limited, its floor is 0 W, and a full A still holds at 0 W. I checked this: A 0 W, the quarter over the limit, PEAK_GUARD_FLOOR_LIMITED.
SAFETY-85 ("Battery is not keeping up"): not fixed. In a 45 min N=2 HOLD run at 6500 W, the loop's integrator is clamped at +5000 W in one episode both before and after, but the OUTPUT_CLAMP episode log lines went from 6 to 20. At N=1 the count went from 0 to 1. Neither easier nor fixed. A reviewer may want to look at the extra lines.
SAFETY-88 and the N=1 charge headroom sizing: untouched.
Docs
DOCS.md got one short paragraph under maintenance. The DECISIONS entries for SAFETY-83 and SAFETY-84 got a pointer to SAFETY-86, and there is a new SAFETY-86 entry. The arbiter order comment now names the HOLD.
Owner decision 2026-09-30: the peak guard wins over the maintenance HOLD, as it does over the drain (SAFETY-83) and the charge (SAFETY-84). Record: `DECISIONS/SAFETY-86-peak-over-hold.md`. Tests: `test_safety86.py`.
## The defect (reproduced on 4ad8298 in the test harness)
| run | 4ad8298 | this PR |
|---|---|---|
| N=1, HOLD, house 6500, aim 3450 | 6500 W quarters, 'Load too high' | 3450 W, no alarm |
| N=2, HOLD, house 6500 (B max 2500) | 4000 W, 'Load too high' | 3450 W (A 551 W, B 2500 W) |
| N=2, HOLD, house 6500, month peak 4000 (aim 3950) | 4000 W | 3950 W |
| house under the aim, N=1 and N=2 | A 0 W | A 0 W, every row identical |
## The fix (`main.py`, `_peak_over_hold()`)
- **N=1:** during a HOLD, a peak floor above 0 W is refiled at `P_MAINTENANCE`, the same move as `_peak_over_drain()`. The battery gives the floor. The Why line reuses "Battery maintenance: 3 051 W, raised for the peak limit". No new strings.
- **N>=2:** the holding battery's pin is raised to the floor minus what the other live batteries may place (their hard `hi`, or their pin). **The others give first and the holding battery gives only the shortfall.** Why this split and not the allocator's normal shares: the hold is maintenance, which ranks above the others' modes and use in the owner's order. It is also the same rule the owner set for an Off battery ("gives way only for the shortfall"). So an Off battery (P_MODE, below maintenance) still gives before the holding one (test part 3).
- **Stops:** a site stop (a SET above `P_PEAK`) decides alone. At N>=2 nothing is raised, and at N=1 the refiled floor stays below `P_SAFETY`. If another battery is pinned to charge (a SAFETY-73 probe), the pin is not raised, so the site is never split both ways.
- **After the peak:** the hold resumes by itself. The rule runs every cycle, so when the guard lets go the command is 0 W again.
- **The level dips below the target: the hold finishes anyway.** The HOLD does not read the level; it ends on its clock. A top-up would mean going back to CHARGE. CHARGE waits for sun or a cheap window (up to 36 h). Meanwhile the battery keeps the site's one maintenance slot, so the other battery's cycle waits, and then a new full hold starts that the guard can interrupt again. The guard only gives while the quarter is at risk, and the next monthly cycle balances again. **Owner may prefer a top-up.** That would be a change in `maintenance.py`'s HOLD branch, not in this rule.
## Tests (`test_safety86.py`, real Controller, safety84_identity harness)
1. N=1, houses 5000/6500/7000: quarters on the aim, the phase stays hold, no alarm. This holds with 5 s spikes too (SAFETY-81 hold). Non-vacuous without the fix.
2. Under the aim (N=1: 1200, 3000, −1500; N=2: 1500, 3000, 5000): A is at 0 W and every row equals the run without the fix.
3. N=2, houses 6500/7000: on the aim, B at 2500 W, and A's pin = floor − 2500 on every cycle where A gives. The same holds with modes on and B Off. No refusal, no opposite split, no alarm.
4. Banked month peak 4000 → aim 3950, at N=1 and N=2.
5. Lost reading (site safety SET): every battery is at 0 W at N=1 and N=2, and the hold gives again after.
6. The level falls from 99 % to 70 % during a 30 min hold: HOLD → IDLE, "cycle COMPLETE", never CHARGE, at N=1 and N=2.
7. Boundaries on claim lists: a floor at, under, or 0.5 W over B's max; B not live, contradicting, refusing, or probing either way; a site vs a unit safety SET; DRAIN or idle not raised; N=1 floor 0 vs 0.5 W vs a ceiling-only claim.
## Mutations (fresh copy per mutant, `__pycache__` purged, `python3 -B`)
| # | mutation in `_peak_over_hold()` | test_safety86 | first red check |
|---|---|---|---|
| M1 | N=1 refile removed | RED | part 1 quarters on the aim |
| M2 | N>=2 pin not raised | RED | part 3 on the aim / shortfall |
| M3 | site-stop check removed | RED | part 7 site safety SET |
| M4 | others not subtracted (A gives the whole floor = allocator-style share) | RED | part 2 rows identical, part 3 |
| M5 | `live` check dropped | RED | part 7 B not live |
| M6 | contradicting unit counted | RED | part 7 B contradicting |
| M7 | others' pin ignored (hard hi used) | RED | part 7 B probing / unit SET |
| M8 | charging-pin guard removed | RED | part 7 B probing to charge |
| M9 | rule also applied to a DRAIN | RED | part 7 "only a HOLD" (test_safety83 red too) |
| M10 | floor of 0 W counts (`lo >= 0`) | RED | part 7 N=1 floor 0 |
| M11 | negative give not dropped (A would charge) | RED | part 2, part 7 floor under B |
| M12 | call site removed | RED | part 1 |
No survivors. `owns_setpoint` in the HOLD test is not mutated: a HOLD always owns the setpoint, so that mutant would be equivalent.
## Pins and other suites
- **No digest re-pinned.** The SAFETY-78/83/84, OPT-11, peak-margin and other identity suites pass unedited against their existing pins.
- `test_safety83.py`: its last check asserted "HOLD not changed at N=1" (SAFETY-83's "Not changed" list). It now asserts the battery gives, bound by the peak. That change is intentional and is this ticket.
- **Separate commit 513183a (not SAFETY-86):** `test_peak_margin.py` part 3 read the simulated September's month peak on the wall clock, so from 2026-10-01 it crashed on every tree, 4ad8298 included (I ran it on 4ad8298: same TypeError). It now reads at the runs' clock (`BASE`). This is test-only and was needed for CI to go green.
- Full suite, run CI-style in `goodwe-addon-checks:1`: 55 of 56 passed. test_named_absence flagged two unguarded reads in test_safety86.py. After fixing those (test only), test_named_absence and test_safety86 pass.
## Out of scope, and whether this changes them
- **SAFETY-87** (the guard grades the emptiest battery): unchanged, and now more visible. At N=2 with B under `peak_soc_floor`, the guard is floor-limited, its floor is 0 W, and a full A still holds at 0 W. I checked this: A 0 W, the quarter over the limit, PEAK_GUARD_FLOOR_LIMITED.
- **SAFETY-85** ("Battery is not keeping up"): not fixed. In a 45 min N=2 HOLD run at 6500 W, the loop's integrator is clamped at +5000 W in one episode both before and after, but the OUTPUT_CLAMP episode log lines went from 6 to 20. At N=1 the count went from 0 to 1. Neither easier nor fixed. A reviewer may want to look at the extra lines.
- **SAFETY-88** and the N=1 charge headroom sizing: untouched.
## Docs
DOCS.md got one short paragraph under maintenance. The DECISIONS entries for SAFETY-83 and SAFETY-84 got a pointer to SAFETY-86, and there is a new SAFETY-86 entry. The arbiter order comment now names the HOLD.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
Part 3 read the simulated September's peak with the wall clock, so from
2026-10-01 it read no peak and crashed on every tree (4ad8298 too).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
During a HOLD the holding battery gives what keeps the quarter at the peak
aim. N=1: the peak floor is refiled at P_MAINTENANCE, as for a drain
(SAFETY-83). N>=2: the hold's pin is raised to the floor minus what the
other live batteries may place, so they give first. A site stop still
decides alone. The hold ends on its clock even if the level dipped.
Below the aim nothing changes (rows identical).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Checked at ba31e7c. Full suite in goodwe-addon-checks:1: 55 of 56 pass. The one failure is test_freeze on my machine, because it needs the ../goodwe hwsim sibling; CI has that and passed.
Findings
R1-1 (fixing, pushed below): at N=2 the moving pin fills the action log. While A gives, A's pin follows the guard's floor. The loop still asks for its full +5000 W, but only pin + 2500 W can be placed. The ALLOCATOR "could not be placed" row triggers on every 1 W change in the unmet amount, and each such row splits the merged OUTPUT_CLAMP row. With a 6500 W house that varies by ±150 W, the 40-row log held only the last ~2 minutes (20 ALLOCATOR + 20 clamp rows in 45 min). Before this PR it held 16 rows, because A sat at 0 W and the unmet amount did not move. Fix: while the holding pack gives at N≥2, cycle() adds a site hold ceiling at the floor (P_MAINTENANCE). This is the same pattern as SAFETY-84's charge ceiling. The site target is then exactly the floor, which N=1 already does. Result: 0 ALLOCATOR rows and 1 clamp row. It is filed only when A gives, so part 2 (under the aim) is unchanged. It can never clash with the floor (hi == lo), and no other site lo exists during a hold (drain needs the one maintenance slot). It reuses the existing Why words "battery maintenance", so there is no new text.
R1-2 (answered, SAFETY-85): does the 6→20 clamp row rise mean a new episode? No. Every row is episode 1; each quarter-end burst is the same episode, re-inserted under an ALLOCATOR row (see R1-1). R1-1 brings it back to 1 row.
R1-3 (DECISIONS): top-up. The owner decided on 2026-10-01 that the hold ends on its clock, with no top-up. The DECISIONS entry now states this as the owner's decision.
R1-4 (test gap, fixed): a raise in the CHARGE phase (mutant R2) was killed only by test_safety84's digest; test_safety86 stayed green. Part 7 now checks that a CHARGE is not refiled at N=1.
Checked and fine
N=1 refile: safety SET still wins. Margin 0: quarters bank 3500 W at N=1 and N=2. Banked peak 4000 → 3950 W. Restart mid-hold (hold started 110 of 120 min earlier): it ends at 600 s and hands back to the loop, never both ways.
N≥2 split: the others give first, and an Off battery's shortfall rule is respected. A SAFETY-73 charging probe blocks the raise. A giving probe is counted. Nothing pushes power both ways in any run.
P1 gap during a hold (N=1, 120 s): the guard goes blind (coverage) for the rest of the quarter, so its floor is 0 and the hold rests at 0 W. That quarter banked 3701 W. This is the guard's designed blind action, not this rule, but the rig test should know about it.
513183a: test only, and correct. peak(when) reads the runs' BASE (2026-09-20), not the wall clock.
test_safety83 flip: flipped only for this ticket (HOLD at N=1 now gives).
Strings: none new. The DOCS paragraph is short and plain.
Mutations (fresh copy per mutant, __pycache__ purged, python3 -B)
#
mutation
result
M2
N≥2 pin not raised
RED test_safety86 p3
M3
site stop ignored
RED p7
M4
others not subtracted
RED p2, p3
M8
charging-probe guard removed
RED p7
M11
negative give kept
RED p2, p7
R1
holding pack gives first (≤2500 W), others the rest
RED p2, p3
R2
raise also in CHARGE
RED test_safety84 only (now also p7)
R3
N=1 refile at P_SAFETY
RED p7
R4
top-up: HOLD → CHARGE under target
RED p6, test_safety83
R5
aim ignores banked peak
RED p4, test_safety83
R6
aim ignores margin
RED p1, p3
R7
holding pack gives shortfall + 50 W
RED p3
SAFETY-87 (next ticket), measured on this branch
N=2, A in HOLD at 99 %, B at 15 % (below peak_soc_floor 20), house 6500 W. The guard grades the emptiest battery (B), so it is PEAK_GUARD_FLOOR_LIMITED. The peak claim is lo=0, hi=inf (reason "SoC floor"), so _peak_over_hold sees no floor and A stays at 0 W. B gives 2500 W through the loop. The first quarter banks 4051 W (the month peak rises), and the next aims under that new peak (4000 W). There is no 'Load too high'; the notice fires on FLOOR_LIMITED and names "15 % against 20 %", which is B's level, while a full A sits idle.
## Review round 1 (SAFETY-86)
Checked at ba31e7c. Full suite in `goodwe-addon-checks:1`: 55 of 56 pass. The one failure is `test_freeze` on my machine, because it needs the `../goodwe` hwsim sibling; CI has that and passed.
### Findings
**R1-1 (fixing, pushed below): at N=2 the moving pin fills the action log.** While A gives, A's pin follows the guard's floor. The loop still asks for its full +5000 W, but only pin + 2500 W can be placed. The ALLOCATOR "could not be placed" row triggers on every 1 W change in the unmet amount, and each such row splits the merged OUTPUT_CLAMP row. With a 6500 W house that varies by ±150 W, the 40-row log held only the last ~2 minutes (20 ALLOCATOR + 20 clamp rows in 45 min). Before this PR it held 16 rows, because A sat at 0 W and the unmet amount did not move. **Fix:** while the holding pack gives at N≥2, `cycle()` adds a site `hold` ceiling at the floor (P_MAINTENANCE). This is the same pattern as SAFETY-84's `charge` ceiling. The site target is then exactly the floor, which N=1 already does. Result: 0 ALLOCATOR rows and 1 clamp row. It is filed only when A gives, so part 2 (under the aim) is unchanged. It can never clash with the floor (hi == lo), and no other site `lo` exists during a hold (`drain` needs the one maintenance slot). It reuses the existing Why words "battery maintenance", so there is no new text.
**R1-2 (answered, SAFETY-85): does the 6→20 clamp row rise mean a new episode?** No. Every row is `episode 1`; each quarter-end burst is the same episode, re-inserted under an ALLOCATOR row (see R1-1). R1-1 brings it back to 1 row.
**R1-3 (DECISIONS): top-up.** The owner decided on 2026-10-01 that the hold ends on its clock, with no top-up. The DECISIONS entry now states this as the owner's decision.
**R1-4 (test gap, fixed): a raise in the CHARGE phase** (mutant R2) was killed only by test_safety84's digest; test_safety86 stayed green. Part 7 now checks that a CHARGE is not refiled at N=1.
### Checked and fine
- N=1 refile: safety SET still wins. Margin 0: quarters bank 3500 W at N=1 and N=2. Banked peak 4000 → 3950 W. Restart mid-hold (hold started 110 of 120 min earlier): it ends at 600 s and hands back to the loop, never both ways.
- N≥2 split: the others give first, and an Off battery's shortfall rule is respected. A SAFETY-73 charging probe blocks the raise. A giving probe is counted. Nothing pushes power both ways in any run.
- P1 gap during a hold (N=1, 120 s): the guard goes blind (coverage) for the rest of the quarter, so its floor is 0 and the hold rests at 0 W. That quarter banked 3701 W. This is the guard's designed blind action, not this rule, but the rig test should know about it.
- 513183a: test only, and correct. `peak(when)` reads the runs' `BASE` (2026-09-20), not the wall clock.
- test_safety83 flip: flipped only for this ticket (HOLD at N=1 now gives).
- Strings: none new. The DOCS paragraph is short and plain.
### Mutations (fresh copy per mutant, `__pycache__` purged, `python3 -B`)
| # | mutation | result |
|---|---|---|
| M2 | N≥2 pin not raised | RED test_safety86 p3 |
| M3 | site stop ignored | RED p7 |
| M4 | others not subtracted | RED p2, p3 |
| M8 | charging-probe guard removed | RED p7 |
| M11 | negative give kept | RED p2, p7 |
| R1 | holding pack gives first (≤2500 W), others the rest | RED p2, p3 |
| R2 | raise also in CHARGE | RED test_safety84 only (now also p7) |
| R3 | N=1 refile at P_SAFETY | RED p7 |
| R4 | top-up: HOLD → CHARGE under target | RED p6, test_safety83 |
| R5 | aim ignores banked peak | RED p4, test_safety83 |
| R6 | aim ignores margin | RED p1, p3 |
| R7 | holding pack gives shortfall + 50 W | RED p3 |
### SAFETY-87 (next ticket), measured on this branch
N=2, A in HOLD at 99 %, B at 15 % (below `peak_soc_floor` 20), house 6500 W. The guard grades the emptiest battery (B), so it is PEAK_GUARD_FLOOR_LIMITED. The peak claim is `lo=0, hi=inf` (reason "SoC floor"), so `_peak_over_hold` sees no floor and A stays at 0 W. B gives 2500 W through the loop. The first quarter banks **4051 W** (the month peak rises), and the next aims under that new peak (4000 W). There is no 'Load too high'; the notice fires on FLOOR_LIMITED and names "15 % against 20 %", which is B's level, while a full A sits idle.
At N>=2 the holding pack's pin follows the guard's floor while the loop
still asks for +5000 W, so the ALLOCATOR "could not be placed" row was
re-logged on every 1 W change and filled the 40-row action log. While the
pin is raised, cycle() now files a site `hold` ceiling at the floor
(P_MAINTENANCE), as SAFETY-84 does for a charge. Filed only when the
holding pack gives; hi == the floor, so it never clashes with it.
Tests: a noisy N=2 house logs no ALLOCATOR row and at most one clamp
row (20 and 20 without the ceiling); the ceiling is the floor itself;
a CHARGE is not refiled at N=1. The DECISIONS entry records the owner's
2026-10-01 decision: the HOLD ends on its clock, no top-up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pushed 1c41a1e for R1-1, R1-3 and R1-4 (verified with git ls-remote).
main.py: _peak_over_hold() returns the floor when the holding pack gives at N≥2. cycle() then files Claim.limit("hold", P_MAINTENANCE, hi=floor) on the site. arbiter.CLAIM_SCOPE adds hold as a site source, and WHY_LIMIT maps it to the existing "battery maintenance".
test_arbiter and test_peakguard part 10f: source counts 15 → 16 and the direct appends 16 → 17.
test_safety86: part 3 adds a noisy-house check (0 ALLOCATOR rows and ≤1 clamp row, against 20/20 without the ceiling, which is non-vacuous). Part 7 adds a check that the ceiling is the floor itself, only when A gives, and that a 5000 W loop ask resolves to it. It also checks that a CHARGE is not refiled at N=1.
DECISIONS: the ceiling bullet; and the owner decision of 2026-10-01: the HOLD ends on its clock, no top-up.
Locally: test_safety86, 83, arbiter, named_absence and messages pass. Waiting for CI, the full suite and the mutants on the new head.
Pushed 1c41a1e for R1-1, R1-3 and R1-4 (verified with `git ls-remote`).
- `main.py`: `_peak_over_hold()` returns the floor when the holding pack gives at N≥2. `cycle()` then files `Claim.limit("hold", P_MAINTENANCE, hi=floor)` on the site. `arbiter.CLAIM_SCOPE` adds `hold` as a site source, and `WHY_LIMIT` maps it to the existing "battery maintenance".
- test_arbiter and test_peakguard part 10f: source counts 15 → 16 and the direct appends 16 → 17.
- test_safety86: part 3 adds a noisy-house check (0 ALLOCATOR rows and ≤1 clamp row, against 20/20 without the ceiling, which is non-vacuous). Part 7 adds a check that the ceiling is the floor itself, only when A gives, and that a 5000 W loop ask resolves to it. It also checks that a CHARGE is not refiled at N=1.
- DECISIONS: the ceiling bullet; and the owner decision of 2026-10-01: the HOLD ends on its clock, no top-up.
Locally: test_safety86, 83, arbiter, named_absence and messages pass. Waiting for CI, the full suite and the mutants on the new head.
CI: checks / unit (pull_request) and infra-pin / fresh both success.
Local full suite: 55 of 55 pass (test_freeze skipped locally; it needs the hwsim sibling, and CI runs it).
Mutants on the new head are all RED in test_safety86: M2, M4, R2 (CHARGE refile, now caught by p7), R8 (no hold ceiling) and R9 (ceiling 50 W above the floor).
refuted: same single episode, rows re-inserted under ALLOCATOR rows; now 1 row
R1-3 top-up
owner decision 2026-10-01 recorded in DECISIONS
R1-4 CHARGE refile caught only by a digest
fixed (p7)
Open, outside this PR: SAFETY-87 (described in round 1). A P1 gap blinds the guard for the rest of the quarter, so the hold rests at 0 W; this is the guard's designed blind action.
## Review round 2: verdict, merge
At head 1c41a1e:
- CI: `checks / unit (pull_request)` and `infra-pin / fresh` both success.
- Local full suite: 55 of 55 pass (test_freeze skipped locally; it needs the hwsim sibling, and CI runs it).
- Mutants on the new head are all RED in test_safety86: M2, M4, R2 (CHARGE refile, now caught by p7), R8 (no `hold` ceiling) and R9 (ceiling 50 W above the floor).
| finding | state |
|---|---|
| R1-1 action-log flood at N=2 | fixed in 1c41a1e |
| R1-2 clamp rows 6→20 | refuted: same single episode, rows re-inserted under ALLOCATOR rows; now 1 row |
| R1-3 top-up | owner decision 2026-10-01 recorded in DECISIONS |
| R1-4 CHARGE refile caught only by a digest | fixed (p7) |
Open, outside this PR: SAFETY-87 (described in round 1). A P1 gap blinds the guard for the rest of the quarter, so the hold rests at 0 W; this is the guard's designed blind action.
Merging with `head_commit_id` pinned to 1c41a1e.
admin
merged commit b8ddfd787f into release/1.02026-10-01 02:27:37 +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.
Owner decision 2026-09-30: the peak guard wins over the maintenance HOLD, as it does over the drain (SAFETY-83) and the charge (SAFETY-84). Record:
DECISIONS/SAFETY-86-peak-over-hold.md. Tests:test_safety86.py.The defect (reproduced on
4ad8298in the test harness)4ad8298The fix (
main.py,_peak_over_hold())P_MAINTENANCE, the same move as_peak_over_drain(). The battery gives the floor. The Why line reuses "Battery maintenance: 3 051 W, raised for the peak limit". No new strings.hi, or their pin). The others give first and the holding battery gives only the shortfall. Why this split and not the allocator's normal shares: the hold is maintenance, which ranks above the others' modes and use in the owner's order. It is also the same rule the owner set for an Off battery ("gives way only for the shortfall"). So an Off battery (P_MODE, below maintenance) still gives before the holding one (test part 3).P_PEAK) decides alone. At N>=2 nothing is raised, and at N=1 the refiled floor stays belowP_SAFETY. If another battery is pinned to charge (a SAFETY-73 probe), the pin is not raised, so the site is never split both ways.maintenance.py's HOLD branch, not in this rule.Tests (
test_safety86.py, real Controller, safety84_identity harness)Mutations (fresh copy per mutant,
__pycache__purged,python3 -B)_peak_over_hold()livecheck droppedlo >= 0)No survivors.
owns_setpointin the HOLD test is not mutated: a HOLD always owns the setpoint, so that mutant would be equivalent.Pins and other suites
test_safety83.py: its last check asserted "HOLD not changed at N=1" (SAFETY-83's "Not changed" list). It now asserts the battery gives, bound by the peak. That change is intentional and is this ticket.513183a(not SAFETY-86):test_peak_margin.pypart 3 read the simulated September's month peak on the wall clock, so from 2026-10-01 it crashed on every tree,4ad8298included (I ran it on4ad8298: same TypeError). It now reads at the runs' clock (BASE). This is test-only and was needed for CI to go green.goodwe-addon-checks:1: 55 of 56 passed. test_named_absence flagged two unguarded reads in test_safety86.py. After fixing those (test only), test_named_absence and test_safety86 pass.Out of scope, and whether this changes them
peak_soc_floor, the guard is floor-limited, its floor is 0 W, and a full A still holds at 0 W. I checked this: A 0 W, the quarter over the limit, PEAK_GUARD_FLOOR_LIMITED.Docs
DOCS.md got one short paragraph under maintenance. The DECISIONS entries for SAFETY-83 and SAFETY-84 got a pointer to SAFETY-86, and there is a new SAFETY-86 entry. The arbiter order comment now names the HOLD.
🤖 Generated with Claude Code
https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
Review round 1 (SAFETY-86)
Checked at
ba31e7c. Full suite ingoodwe-addon-checks:1: 55 of 56 pass. The one failure istest_freezeon my machine, because it needs the../goodwehwsim sibling; CI has that and passed.Findings
R1-1 (fixing, pushed below): at N=2 the moving pin fills the action log. While A gives, A's pin follows the guard's floor. The loop still asks for its full +5000 W, but only pin + 2500 W can be placed. The ALLOCATOR "could not be placed" row triggers on every 1 W change in the unmet amount, and each such row splits the merged OUTPUT_CLAMP row. With a 6500 W house that varies by ±150 W, the 40-row log held only the last ~2 minutes (20 ALLOCATOR + 20 clamp rows in 45 min). Before this PR it held 16 rows, because A sat at 0 W and the unmet amount did not move. Fix: while the holding pack gives at N≥2,
cycle()adds a siteholdceiling at the floor (P_MAINTENANCE). This is the same pattern as SAFETY-84'schargeceiling. The site target is then exactly the floor, which N=1 already does. Result: 0 ALLOCATOR rows and 1 clamp row. It is filed only when A gives, so part 2 (under the aim) is unchanged. It can never clash with the floor (hi == lo), and no other siteloexists during a hold (drainneeds the one maintenance slot). It reuses the existing Why words "battery maintenance", so there is no new text.R1-2 (answered, SAFETY-85): does the 6→20 clamp row rise mean a new episode? No. Every row is
episode 1; each quarter-end burst is the same episode, re-inserted under an ALLOCATOR row (see R1-1). R1-1 brings it back to 1 row.R1-3 (DECISIONS): top-up. The owner decided on 2026-10-01 that the hold ends on its clock, with no top-up. The DECISIONS entry now states this as the owner's decision.
R1-4 (test gap, fixed): a raise in the CHARGE phase (mutant R2) was killed only by test_safety84's digest; test_safety86 stayed green. Part 7 now checks that a CHARGE is not refiled at N=1.
Checked and fine
513183a: test only, and correct.peak(when)reads the runs'BASE(2026-09-20), not the wall clock.Mutations (fresh copy per mutant,
__pycache__purged,python3 -B)SAFETY-87 (next ticket), measured on this branch
N=2, A in HOLD at 99 %, B at 15 % (below
peak_soc_floor20), house 6500 W. The guard grades the emptiest battery (B), so it is PEAK_GUARD_FLOOR_LIMITED. The peak claim islo=0, hi=inf(reason "SoC floor"), so_peak_over_holdsees no floor and A stays at 0 W. B gives 2500 W through the loop. The first quarter banks 4051 W (the month peak rises), and the next aims under that new peak (4000 W). There is no 'Load too high'; the notice fires on FLOOR_LIMITED and names "15 % against 20 %", which is B's level, while a full A sits idle.Pushed
1c41a1efor R1-1, R1-3 and R1-4 (verified withgit ls-remote).main.py:_peak_over_hold()returns the floor when the holding pack gives at N≥2.cycle()then filesClaim.limit("hold", P_MAINTENANCE, hi=floor)on the site.arbiter.CLAIM_SCOPEaddsholdas a site source, andWHY_LIMITmaps it to the existing "battery maintenance".Locally: test_safety86, 83, arbiter, named_absence and messages pass. Waiting for CI, the full suite and the mutants on the new head.
Review round 2: verdict, merge
At head
1c41a1e:checks / unit (pull_request)andinfra-pin / freshboth success.holdceiling) and R9 (ceiling 50 W above the floor).1c41a1eOpen, outside this PR: SAFETY-87 (described in round 1). A P1 gap blinds the guard for the rest of the quarter, so the hold rests at 0 W; this is the guard's designed blind action.
Merging with
head_commit_idpinned to1c41a1e.