SAFETY-25: record the owner's decision - option a, with alloc-01 as the revisit trigger
checks / unit (pull_request) Successful in 3m43s

The product owner answered SAFETY-25's §1 on 2026-09-02: OPTION A. The BMS
cut-out is the DELIBERATE and only backstop for demand-driven discharge on the
grid-following loop, and Option B - a loop-side reserve floor - is DEFERRED
rather than rejected.

Option A is the shipped behaviour, so this is a recording, not a change. `app/`
is touched in exactly two places and both are comments that claimed the question
was still open (freeze.py's finding, peakguard.py's point 2). No setpoint, no
claim, no option, no default moved. The 17-suite run is green on the same
numbers as before.

WHY DEFERRING IS THE SAFE DIRECTION. Option B's proof - bms_soc_floor <
loop_soc_floor <= peak_soc_floor, run through the real arbiter in
test_peakguard.py part 10d - has an unstated premise: the reserve predicate and
discharge_permitted() read the SAME state of charge. With two packs they read
different numbers. The reserve binds on the emptier pack and files hi = 0; the
peak guard, reading the other pack or an average, files lo = excess > 0; lo > hi
is arbiter clause 3 and the answer is 0 W - the battery parked in the billed
quarter, which is the failure the memo itself marks fatal. And no config
validator can catch it: it is a runtime divergence between two readings, not a
config ordering, so CFG-01's shape of check passes on the exact tree that parks
the battery. Option A's cost is energy; Option B's cost built too early is the
one quarter of the month that sets the bill.

THE REVISIT TRIGGER IS ALLOC-01, recorded in §0c. Under ARCH-01's host-side
allocator the two claims stop sharing a list: the loop floor becomes a
per-inverter `hi` consumed as an INTERVAL at stage A, while the peak floor stays
a site `lo` at stage B. Different claim lists, different stages, structurally
unable to cross - a stronger argument than the ordering rule, because it makes
the crossing unreachable rather than harmless. That is the shape in which Option
B becomes safe to build.

WHAT HAS TO BE RE-EARNED AT N=2 is stated plainly in §0d rather than left for
someone to discover. SAFETY-27 now names five floors across two packs, so "the
SoC" stops being a scalar the config can bound. And part 10f - the pin that made
SAFETY-25 mergeable - CANNOT EXPRESS TWO PACKS: its dynamic half is a scalar
0-100 % SoC sweep against a single-series capture, and a two-pack exposure lives
in the DIFFERENCE between two readings, which that sweep visits at no
resolution. Its static half, the AST scan pinning the eight claim sources, still
bites at N=2; the dynamic half goes silent. Merging a two-pack reserve on 10f's
current green would be merging on a check that cannot see the failure.

The options analysis is kept intact on purpose - §1-§7 stand exactly as written,
including §5's "(recommended)" on Option B and §6's recommendation, with a new
§0 saying what was decided and §6 marked as answered. A memo that recorded the
verdict and deleted the reasoning is the failure this DECISIONS/ directory
exists to prevent.

Part 10e's check moved with the memo: it used to pin the honest "Status: OPEN"
line and now pins that the memo names the option, the date and the trigger AND
still carries both options and the recommendation. The tripwire itself stays -
a ninth claim source in Controller.cycle() still takes 10f red on the line it is
written, and "deferred" is precisely the state in which someone builds Option B
by accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-09-02 00:46:54 +02:00
co-authored by Claude Opus 5
parent 1c59cfeaf5
commit ab6fbe2f9e
6 changed files with 264 additions and 27 deletions
@@ -1,20 +1,156 @@
# SAFETY-25 — does the grid-following loop get a reserve floor?
**Status: OPEN. This is a memo, not a decision.** It sets out the options, what
each costs, what each protects, and a recommendation. **The choice belongs to the
product owner**, because it decides whether this house's battery is allowed to
discharge to its BMS cut-out on a sustained-load evening, and that moves real
energy.
**Status: DECIDED 2026-09-02 — Option A.** The product owner has answered §1:
**the BMS cut-out stays the only backstop for demand-driven discharge on the
grid-following loop.** **Option B — a loop-side reserve floor — is DEFERRED**,
not rejected, with an explicit revisit trigger recorded in §0c: **`ALLOC-01`**.
Filed with SAFETY-25. The other half of that ticket — making `peak_soc_floor`'s
scope honest in the config, the docs and on the status page — **shipped in the
same PR** and did not wait on this, because it is required whichever way the
answer goes.
Option A **is the shipped behaviour**, so recording this decision changed no
code. The honesty half of SAFETY-25 — `peak_soc_floor`'s scope made true in the
config, in `DOCS.md` and on the status page — had already shipped and was
required under every option. What was missing was the answer, and the answer is
now here rather than in a Trello card that scrolls away.
**Everything from §1 down is the memo exactly as it was written before the
decision, and is deliberately left intact.** It is the record of *why*, and a
future reader arriving at the revisit trigger needs the options analysis, the
costs, the measured replay and the trap in Option B's obvious shape — not a
one-line verdict. §5 still labels Option B *"(recommended)"* and §6 still says
"Option B is what I would build"; those were the engineering recommendation put
to the owner, they are preserved as written, and **§0 is what was decided.**
Filed with SAFETY-25. Card 6a94b2b98b73123d520e69f2. The other half of that
ticket — making `peak_soc_floor`'s scope honest in the config, the docs and on
the status page — **shipped in SAFETY-25's own PR** and did not wait on this,
because it was required whichever way the answer went.
*This directory exists so a recorded decision has somewhere to live that is not a
comment in a source file nobody reads and not a Trello card that scrolls away.
`SAFETY-27` asks for a second one (the naming of the floors); it belongs here
too.*
too, and §0d says what N=2 does to it.*
---
## 0. The decision
### 0a. What was decided, and the owner's reasoning
**Option A. The BMS remains the only backstop for demand-driven discharge on the
grid-following loop. Option B is deferred until `ALLOC-01` exists.**
The reasoning is not "Option B is wrong" — §5 and §6 still stand as engineering.
It is that **deferring is the safe direction, and building now is not.**
Option B's safety proof is the ordering constraint in §5:
bms_soc_floor < loop_soc_floor <= peak_soc_floor
That proof has an unstated premise: **the reserve predicate and
`discharge_permitted()` must read the SAME state of charge.** At N=1 they do,
which is why the proof reads as airtight, and why part 10d can run it through
the real arbiter and watch the contradiction disappear.
**With two packs they read different numbers.** The reserve binds on whichever
pack is emptier and files `hi = 0`; the peak guard, reading the other pack or an
average of the two, is *not* floor-limited and files `lo = excess > 0`. Then
`lo > hi`, arbiter clause 3 fires, and the result is **0 W — the battery parked,
in the billed quarter.** That is precisely the failure §5 marks *"⚠️ fatal if
missed"*, arriving by a route the ordering rule cannot close, because
`loop_soc_floor <= peak_soc_floor` is still perfectly satisfied while it happens.
**And no config-ordering validator can catch it.** The rule §5 proposes — refuse
the bad ordering at startup, in CFG-01's manner — grades *configuration*. This is
a **runtime divergence between two readings**, not a config ordering. The startup
check would pass, every time, on the exact tree that parks the battery.
So the shape of the risk decides the direction: **Option A's cost is energy
(§5, "every watt below the reserve becomes billed import" — inverted: the pack
may sit on its own cut-out). Option B's cost, built too early, is a parked
battery in the one quarter of the month that sets the bill.** One of those is a
tariff line; the other is the failure this memo already calls fatal. Take the
one that cannot park the battery, and build Option B when its proof holds again.
### 0b. What this decision does NOT say
- It does **not** say a loop-side reserve is a bad idea. §5's protection
argument — keeping the pack off its own cut-out, which is the `MAINT-01` drift
condition — is unchanged and still wanted.
- It does **not** close §4's fifth-key question. `SAFETY-27` still owns the
naming of the floors, and any future `loop_soc_floor` still lands after it.
- It does **not** weaken §6 item 3: **the tripwire in `test_peakguard.py`
part 10 stays.** A deferred option needs its tripwire more than an open one
does, because "deferred" is exactly the state in which someone builds it by
accident. Part 10f still takes red on the line a ninth claim source is
written. Do not remove it.
### 0c. The revisit trigger: `ALLOC-01`
**`ALLOC-01` is the trigger, and it is a trigger because it changes the SHAPE of
the two claims, not because it changes anyone's mind.**
`ARCH-01` decided that the site grid target is **allocated host-side, above the
driver seam**: one integrator on P1, a sum-invariant split into per-inverter
shares, and each inverter told a function of **its own share** only. It also
decided that **per-inverter constraints are consumed by the allocator as
INTERVALS, never filed as site-level claims** — which is the structural fix for
the cross-inverter `lo > hi -> 0 W` contradiction.
Under the allocator, the two claims that collide in §0a stop sharing a claim
list at all:
| | today (N=1, no allocator) | under `ALLOC-01` |
|---|---|---|
| the loop reserve | a site `hi` at `P_LOOP` | a **per-inverter `hi`**, consumed at **stage A** as that unit's interval |
| the peak floor | a site `lo` at `P_PEAK` | still a **site `lo`**, filed at **stage B** against the site target |
**Different claim lists, evaluated at different stages, structurally unable to
cross.** The reserve bounds one inverter's share; the peak floor bounds the site
sum. There is no arbiter resolution in which one is the other's `lo` and `hi`,
so §0a's failure cannot be constructed — not "is guarded against", *cannot be
constructed*. That is the same class of argument as §5's ordering rule but a
stronger one: §5 makes the crossing harmless, `ALLOC-01` makes it unreachable.
**That is the shape in which Option B becomes safe to build.** When `ALLOC-01`
exists, reopen this memo at §5 Option B, restate the reserve as a stage-A
per-inverter interval, and the ordering rule becomes an implementation
convenience rather than the load-bearing proof.
### 0d. What has to be RE-EARNED at N=2, stated plainly
Two things this decision leans on do not survive a second pack, and saying so is
part of the decision rather than a footnote to it.
**1. `SAFETY-27` now names FIVE floors across TWO packs.** §4's table already
carries four keys plus "the loop"; two of them ship the same default of 20 from
different cards. At N=2 each of those is a floor *per pack*, and "the SoC" stops
being a scalar the config can bound — which is the same divergence that makes
§0a's contradiction possible. `SAFETY-27` inherits a harder question than the
one it was filed with.
**2. `test_peakguard.py` part 10f — the guarantee that made SAFETY-25 mergeable —
CANNOT EXPRESS TWO PACKS, and will not cover the two-pack case.** Say it flatly,
because it is the load-bearing pin (§6 item 3: PR #31 shipped this section
claiming a tripwire it did not have, and the reviewer disproved the claim by
building Option B under a green suite):
- 10f's dynamic half is a **scalar 0–100 % SoC sweep** — one number swept over
one range, in the idle and floor-limited states — against a **single-series
capture** (§7: the fixtures carry signed net power at the meter and nothing
else; the pack is modelled, and one pack is modelled).
- A two-pack exposure lives in the **difference** between two SoC readings. A
sweep over one scalar visits no point of that space, at any resolution. It
cannot fail on the divergence because it cannot represent it.
- 10f's **static** half — the AST scan pinning the eight claim sources in
`Controller.cycle()` — survives, and is the half that keeps working: a ninth
source still goes red on the line it is written, at N=2 as at N=1. It is the
*dynamic* half, the one that proves the named sources actually reach the
arbiter across the whole state space, that goes silent.
So: **10f remains a valid tripwire against building Option B today, and is not a
guarantee about N=2.** Whoever reaches the `ALLOC-01` revisit inherits the job of
re-earning it — a two-pack fixture with two SoC series, or a claim-shape argument
at stage A that does not need one. Merging a two-pack reserve on 10f's current
green is merging on a check that cannot see the failure.
---
@@ -216,6 +352,11 @@ around.
## 6. Recommendation
⚠️ **This section is the recommendation that was PUT to the owner, kept as
written. It was answered on 2026-09-02 — see §0. The owner took Option A and
deferred Option B to `ALLOC-01`; item 3 below (keep the part 10 tripwire)
was taken as written and still stands.**
**Option A is honest and cheap; Option B is what I would build.** Concretely:
1. **Now (shipped with SAFETY-25):** the honesty half. `peak_soc_floor`, its