SAFETY-42: a unit that stops delivering, and the discriminator the card asked for does not exist
checks / unit (pull_request) Successful in 5m54s

A unit standing at a non-zero share and producing nothing is seen by NOTHING.
SAFETY-30's per-unit sign gate needs OPPOSITION on both sides of 250 W and a
unit at 0 W is not opposing, it is ABSENT. The site sum still agrees with the
meter in direction. Circulating power is exactly zero when one unit does
nothing. The only trace left is `reason == "saturated-freeze"`, which
clamp.py:93-96 documents as the alarm an operator learns to ignore. It is
HW-01's shape, which ran 6 h 25 m with all three layers spectators.

⚠️ THE CARD'S OWN DISCRIMINATOR WAS BUILT FIRST AND FALSIFIED, AND THAT IS THE
MAIN CONTENT. SAFETY-42 asks for "saturation persisting past N while
|grid error| > X, both derived". Run on the two-master rig it fails in BOTH
directions, and no (N, X) fixes either:

  FALSE POSITIVE  `plantsim.Plant.limit()` returns 0 W for a discharge command
      into a pack at its own floor, and a wedged unit produces 0 W too. Load
      2000 W, equal weights, 2000 W per-unit cap, 120 cycles: THE TWO ARMS ARE
      IDENTICAL ON ALL 120 in grid, site target, every share and every measured
      watt. Both saturated-freeze on 116 of them, both hold |grid error| over
      300 W on all 120 and settle at 1200 W, both read circulating 0.0 W. The
      predicate's inputs do not differ, so no threshold on them can differ. And
      "a cold pack at its floor must not alarm" is the card's own AC 2.

  FALSE NEGATIVE  the standing grid error is what a HEALTHY SIBLING ERASES.
      Load 800 W, same config, B wedged: over the last 30 of 200 cycles the loop
      is frozen on 30 of 30 while the worst |grid error| is 30 W, with B standing
      at +770 W and delivering 0 W. Any X above 30 W is silent for the whole run
      on the exact fault the card describes.

WHAT REPLACES IT. Per unit, for a discharge share, `share - measured`; readable
because of a reduction that only exists at N>=2:

    a unit is named while it is short by more than `saturation_w` with NO
    ACCOUNT for it, and the units in that state are a PROPER SUBSET of the units
    asked to discharge.

THE ACCOUNT keeps the cold pack out and is the only thing that can: the two
cases are identical in the power domain, so the discriminator has to be a
non-power fact, and there is exactly one - the pack's own SoC against its own
`bms_soc_floor`, asked through `peakguard.discharge_permitted()` rather than
transcribed. ⚠️ `bms_soc_floor` ALONE, not `pack_floor_pct()`'s conjunction with
`peak_soc_floor`: that floor bounds a money-saving discharge only and using it
would explain away a wedged unit anywhere between the two.

THE PROPER SUBSET keeps a whole-site cause out - a derate this add-on cannot see
hits every unit; one box that stopped listening does not - and it is what makes
this INERT AT N=1, by algebra rather than by a roster guard.

IT REPORTS. IT DOES NOT ACT, and the strongest argument is for acting. Dropping
the wedged unit from `live` genuinely recovers a site the shipped path does not:
at load 1500 W, kept in the split the site settles at 800 W with a 700 W error
and NEVER comes inside 100 W in 150 cycles (the freeze pins the target, so the
sibling's SHARE never grows); dropped, it delivers 1500 W from cycle 14. What
refuses it is that the only re-allocation this add-on can express is
all-or-nothing: a unit derated to 50 % is short by more than saturation_w with
nothing to explain it, so this detector names it too, and at load 3000 W
dropping it leaves the site delivering 2000 W against 2400 W kept - 400 W WORSE.
A partial re-allocation is what would make acting safe and the allocator cannot
express it. That is a card.

control.py is NOT MODIFIED. The freeze shapes the loop; this reports on it.

NUMBERS, MEASURED. The watts side is `Tuning.saturation_w`, read at runtime, so
there is no fourth CFG-03 copy and it cannot drift from the number the control
law itself calls "not following". `delivery_hold_s: 185` comes off 2640
closed-loop two-inverter runs in which nothing is faulty (6 gains x 5 slews over
the whole schema range, across load reversals, a flapping load and ASYMMETRIC
SPLITS - a family SAFETY-30's campaign did not have and the one that dominates
here) plus 1890 runs at a roster of ONE as the control:

    threshold   reversals   flapping   weighted   N=1 control
     * 500 W      20.0 s      0.0 s      30.0 s      0.0 s

The N=1 column is 0.0 s at EVERY threshold in all 1890 runs, and the flapping
column - the family that defeated SAFETY-30's site window - is zero too, because
a common stimulus makes both units short at once and the subset stops being
proper. ⚠️ The curve is NOT monotone (55.0 s at 250 W against 40.0 s at 100 W):
lowering the threshold can turn a proper subset into the whole roster and BREAK
the run.

⚠️ THE MARGIN IS MEASURED, NOT FELT, BECAUSE INVERTER B DOES NOT PHYSICALLY
EXIST. `plantsim.INVERTER_B` is A COPIED, `measured=False`, so every two-inverter
figure above is two copies of A. Sweeping a different second unit: the worst
legitimate episode is insensitive to B's RAMP (30.0 s at half A's) and scales
with its LAG - 45.0 s at twice A's measured 10 s, 90.0 s at four times it.
185 s is 6.2x the measured worst, 2.06x the four-times-slower case, 6.2x
LINK_STALE_S and 4.9x inside the 900 s quarter. Deliberately not 180, which is
SAFETY-02's site hold.

SAFETY-34. `unit/<name>/delivery_tripped` in the store, one notification id per
unit, re-raised on the first cycle of every process, cleared by the Start press.
No flat mirror: a rollback lands on a version that drives one actuator, where
this check is inert.

⚠️ SCHEMA CHANGE - `delivery_hold_s` joins options: and schema:, so this needs a
version bump before it ships. Additive and defaulted, so an existing options blob
stays valid.

STATED GAPS: charge shares are not graded (no configured charge ceiling exists,
so a constant-voltage taper cannot be explained and arming it would fire on every
maintenance charge); every unit wedged at once is not caught (the subset is not
proper and no instrument here separates that from a plant that cannot).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
glenn schrooyen
2026-09-06 15:46:00 +02:00
co-authored by Claude Opus 5
parent c8dcb38a95
commit ccc4bf100c
12 changed files with 2329 additions and 5 deletions
@@ -0,0 +1,427 @@
# SAFETY-42 — a unit that stops delivering its share, the discriminator the card asked for, and why that discriminator does not exist
**Status: DECIDED 2026-09-06.** A new per-inverter check, `app/delivery.py`,
names an inverter that is **standing at a discharge share it is not producing**
while at least one sibling is not in that state and nothing this add-on can read
explains it. It **reports and does not act**. At a roster of one it is inert by
construction.
⚠️ **This record also FALSIFIES the discriminator the card proposed.** SAFETY-42
asked for *"saturation persisting past N while |grid error| stays above X, with N
and X derived"*. That predicate was built and run on the rig before anything was
written, and it fails in **both** directions — not for want of better N and X,
but because of what the quantity is. §2 is the measurement. This is the
SAFETY-39 shape: saying the criterion is wrong was worth more than five
near-misses at satisfying it.
Filed with SAFETY-42, card `6a9d498a49082c0086e525c1`.
---
## 0. What is uncovered, and why it is structural rather than a threshold
A unit delivering **0 W against a non-zero share** is seen by nothing:
| guard | why it is silent |
|---|---|
| [[SAFETY-30]] per-unit sign | gates on **opposition ≥ 250 W on both sides**. A unit at 0 W never opens it — it is not opposing, it is **absent** |
| SAFETY-30 site sign | the sum still **agrees with the meter in direction**; only the magnitude is short |
| [[SAFETY-31]] circulation | `Σ\|Bᵢ\| − \|ΣBᵢ\|` is **zero** when one unit does nothing — circulation needs two units pulling *against* each other |
| the saturation freeze | **fires** — and `clamp.py`:93-96 says of exactly this: *"A loop can be legally saturated all day — a cold pack, a curtailed inverter, an export limit — so a saturation-shaped alarm is one an operator learns to ignore"* |
So the one signal that does fire is the one this codebase documents as
unreadable, and that documentation is **right**. This card therefore needed a
different signal, not a louder one.
It is [[HW-01]]'s shape. On 2026-09-02 the comms subsystem failed and the power
stage went on executing its last instruction for **6 h 25 m** with all three
layers spectators. At N=2 the same fault leaves a healthy sibling regulating
alone, the site short by up to one unit's share, and nothing saying so.
## 1. The rig
Everything in §2 and §3 was measured on `admin/goodwe`'s two-master rig
(TEST-19), used **read-only** and run container-free, from a harness in a
scratch tree. Real and imported, never transcribed: the control law
(`control.compute`, `Tuning`), the allocator (`arbiter.allocate_record` →
`allocator.plan`), the per-unit envelope the shipped `supervised` and `device`
claims build, the sign predicate (`watchdog.disagrees`), the circulating sensor
(`circulation.circulating_w`), the check under test (`delivery.delinquent`,
`short_by`, `account`), the wire (`frame_check.setpoint_atomic`, the firmware's
own encoder, in and back out through `plantsim.parse`/`decode`), and the plant
(`plantsim.Plant`). The per-unit reading is **holding register 0x0518**, the one
the live `batt_entity` binds.
Modelled, and therefore not evidence about the thing modelled: the master's
failsafe machine (absent), and the two fault injections —
* **wedged** — the write never reaches the plant, so its latched command stands.
Started at 0 W, which is *"delivering 0 W against a non-zero share"*.
* **at its floor** — the write arrives and `Plant.limit()` zeroes it, because
`soc <= soc_min`. This is the benign case the acceptance criterion names.
### ⚠️ Inverter B does not physically exist
`plantsim.INVERTER_B` is **inverter A copied**, `measured=False` (NOTES.md
§1.1b; the datasheet-and-serial figures 4600 W / 20.48 kWh / floor 10 % are
deliberately *not* in the model). **Every two-inverter figure in this document
is two copies of inverter A.** §3.3 is the sensitivity sweep that sizes the
margin that costs.
### Reproducing it
The harness is **not committed here**, for the reason SAFETY-30's and
SAFETY-31's were not: it imports `plantsim` and `frame_check`, which live in the
other repository and are not present in this one's CI image. What it does is
small enough to restate: build two `plantsim.Inverter`s from `INVERTER_A` /
`INVERTER_B`; close the loop with the add-on's own `compute()` on the SITE sum
(`Controller.site_batt_w`) and `arbiter.allocate_record()` over per-unit
envelopes of `±min(max_w, rating)`; put each share on its own wire through
`Inverter.command()`, honouring a configurable **write stagger** so B's number
can land up to four cycles after A's (the serialised write path); advance
`Site.settle()` one control cycle; and record, per unit, the command standing
*before* the write and that unit's 0x0518 power. Then grade each cycle with the
**shipped** `delivery.delinquent()` and take the **longest run of consecutive
cycles** on which it names anybody — not a count of naming samples, because the
window is cleared by any cycle that does not.
⚠️ MUTATION.md's note applies: this harness writes `__pycache__` into **both**
trees. Run it with `python3 -B` and purge.
## 2. The card's discriminator, measured — and it does not work
### 2.1 It fires on the benign case, and no (N, X) can stop it
`Plant.limit()` returns 0 W for a discharge command into a pack at its own
floor. A wedged unit that never receives its command also produces 0 W. The two
are not merely similar:
> **Load 2000 W, equal weights, 2000 W per-unit cap, 120 control cycles.
> The two arms are IDENTICAL on all 120 cycles** in (grid, site target, each
> unit's share, each unit's measured power), to the last emitted watt. Both
> report `reason == "saturated-freeze"` on **116 of the 120** cycles (the other
> four are `slew-limited`, before it engages on cycle 4), both hold |grid error|
> over 300 W on **all 120** and settle at **1200 W**, and `circulating_w` is
> **0.0 W** in both.
A predicate whose inputs are *saturation duration* and *|grid error|* reads the
same number on both arms at every cycle. There is no N and no X. And *"a
legitimately saturated site — cold pack at its floor — does **not** alarm"* is
this card's own acceptance criterion.
`test_delivery.py` part 4 asserts that identity on the add-on's own end-to-end
fixture, so it goes red if it ever stops being true.
### 2.2 It is silent on the headline case, because a healthy sibling erases it
The standing grid error is exactly the thing a working sibling removes.
> **Load 800 W, equal weights, 2000 W per-unit cap, B wedged, 200 cycles.**
> Over the last 30 cycles the loop is saturation-frozen on **30 of 30** — so
> "saturation persisting past N" is true for every N — while the worst
> **|grid error| in that window is 30 W**. B is standing at a **+770 W**
> discharge share and delivering **0 W**.
Any X above 30 W leaves the card's predicate silent for the whole run, on the
exact fault it was written for. The card's own sentence — *"a healthy sibling
regulating alone, the site short by up to one unit's rating"* — describes the
case, and the grid error is the one number that cannot report it.
### 2.3 What that leaves
The derivation instruction stands and is honoured in §3. What could not be
derived was a threshold on the wrong quantity. Both failures are written into
`delivery.py`'s docstring as well, because the next person to read the card will
propose the same predicate.
## 3. The quantity that does work
Per unit, for a share that asks it to **discharge**:
```
short_by(i) = share_i − measured_i # watts that did not arrive
```
and the reduction that makes it readable:
> a unit is **delinquent** while it is short by more than `saturation_w` with
> **no account for it**, and the set of units in that state is a **proper
> subset** of the units that were asked to discharge.
### 3.1 The account — the only thing that separates a wedged unit from a cold pack
§2.1 says there is nothing in the power domain. There is exactly one non-power
fact this add-on holds about a pack: **its own state of charge against its own
`bms_soc_floor`**, which SAFETY-32 made a required per-entry key. `account()`
asks it through **`peakguard.discharge_permitted()`** — public since SAFETY-32
precisely so that this question has one answer — and writes its own sentence,
because that function's wording names the *peak guard's* floor and would be a
false statement here.
Three things account for a shortfall, and each is a state already being handled:
| account | why it is not this fault |
|---|---|
| the unit is not live | `live_units()` has already dropped it from the split and the action log says so — firing inside that is the SAFETY-15/19 failure |
| the unit is parked by a sign trip | it is commanded 0 W on purpose; not delivering is compliance |
| `discharge_permitted(soc, bms_soc_floor)` | the pack is at or below its own cut-out and physically cannot |
⚠️ **The floor passed in is `bms_soc_floor` alone, NOT
`peakguard.pack_floor_pct()`'s conjunction with `peak_soc_floor`.** That
conjunction is right for the peak guard and wrong here: `peak_soc_floor` bounds
a money-saving discharge only (SAFETY-25), ordinary self-consumption discharges
straight past it, and using it would **explain away** a wedged unit sitting
anywhere between the two floors.
⚠️ **Every unknown accounts for the shortfall**, i.e. fails toward silence —
the opposite direction to `discharge_permitted()`'s own caller, and the right
one here: there it withholds a discharge, here it withholds an **alarm**. The
cost is bounded, because the same absence stops the site through `inputs_ok`
(an unreadable SoC) or is refused by the roster schema (an unstated floor).
### 3.2 The proper subset — what only exists at N ≥ 2
A derate this add-on cannot see — thermal, grid-code, a DC-side restriction —
hits **every** unit that was asked. One box that has stopped listening does not.
So if every asked unit is short and unexplained, this module says **nothing**:
it has no instrument that tells that from a plant which simply cannot deliver,
and reporting it anyway would be the saturation-shaped alarm produced by the
guard written to replace it.
**At N = 1 the subset is never proper, for every input**, so the check is inert
there — not by a roster guard, but by the algebra, the same way
`circulation.circulating_w()` is identically zero at N ≤ 1. That is AC 5, and
§3.3's control arm measures it rather than asserting it.
### 3.3 The window — the one number this card chose
**2640 closed-loop runs at a roster of two in which NOTHING is faulty** — every
unit executes the share the allocator gave it — over 6 gains × 5 slews, the
whole range `config.yaml` accepts for `gain` `float(0.05,1.0)` and `slew_w`
`int(0,5000)` (0 = follow the rail), across three families:
* **1050 load reversals** — 7 sizes (±300 W … ±5000 W) × 5 write staggers;
* **840 flapping loads** — 7 periods (10 s … 600 s) × 4 amplitudes;
* **750 weighted reversals** — 5 splits (0.5, 0.7, 0.9, 0.99, 0.1) × 5 staggers
on a ±2200 W reversal. ⚠️ **This family did not exist in SAFETY-30's campaign
and it is the one that dominates here**, for a mechanical reason: an
asymmetric split gives one unit a much larger step than its sibling, so the
big one is still ramping while the small one has settled — which is precisely
the *proper subset* this check keys on.
Every run is executed a second time at a **roster of one** on the same stimulus.
**The N=1 arm is the control**, and it is what separates *"a property of the
check"* from *"a property of the second inverter"*.
Worst **CONTINUOUS** legitimate episode, as a function of the shortfall
threshold:
| threshold | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (1890 runs)** |
|---:|---:|---:|---:|---:|
| 100 W | 40.0 s (608) | 0.0 s (0) | 75.0 s (576) | **0.0 s (0)** |
| 250 W | 55.0 s (490) | 0.0 s (0) | 60.0 s (480) | **0.0 s (0)** |
| **500 W** | **20.0 s (206)** | **0.0 s (0)** | **30.0 s (368)** | **0.0 s (0)** |
| 700 W | 20.0 s (125) | 0.0 s (0) | 20.0 s (174) | **0.0 s (0)** |
| 1000 W | 15.0 s (70) | 0.0 s (0) | 15.0 s (111) | **0.0 s (0)** |
| 1500 W | 10.0 s (51) | 0.0 s (0) | 10.0 s (50) | **0.0 s (0)** |
| 2000 W | 10.0 s (24) | 0.0 s (0) | 5.0 s (23) | **0.0 s (0)** |
Three findings worth stating rather than leaving in the table:
1. **The N=1 column is zero everywhere, in all 1890 runs and at every
threshold.** That is the compatibility promise measured, not argued.
2. **The flapping family is zero everywhere too** — and it is the family that
defeated SAFETY-30's site window (980 s at two inverters, 1000 s at one, no
window inside the billing quarter surviving it). It cannot touch this check,
because a common stimulus makes both units short at the same time and the
subset stops being proper. That is the proper-subset rule earning its place
on a case it was not designed for.
3. **The curve is NOT monotone** — 55.0 s at 250 W against 40.0 s at 100 W —
and that is a property of the reduction rather than noise: lowering the
threshold puts MORE units into the short set, which can turn a proper subset
into the whole roster and **break** the run. Both the numerator and the
denominator move. Do not read this table as a sensitivity curve.
#### The watts side is not a new option — it is `Tuning.saturation_w`
`main.py` hands the check `self.tuning.saturation_w` (500 W) and there is no
`delivery_w` key anywhere. That is a derivation, not a saving:
* it is already **the gap at which `compute()` itself concludes the inverter is
not following** and freezes the integrator. Below it the shipped control law
calls the divergence ordinary tracking, and an alarm under a threshold the
control law calls normal is an alarm about nothing;
* `saturation_cycles` (3) consecutive cycles over that same gap are needed
before the freeze engages, so **every cycle this counts is a cycle the freeze
has already seen**. This is a reduction over the freeze's input, not a second
opinion about it;
* CFG-03 already tracks three places a default has to be kept in step. A fourth
copy could drift, and the drift that matters has a direction: an operator who
raises `saturation_w` has told the loop that a bigger gap is ordinary
tracking, and a check that stayed put would then alarm on it.
**Rejected:** a `delivery_w` option, for the three reasons above. A margin above
`saturation_w` (the way `circulation_w` sits 1.75× above its knee) was also
rejected: there is no knee here — the table above is not monotone — so a margin
would be a preference, and all the margin this check needs is in the window.
#### `delivery_hold_s: 185` — schema `int(30,900)`
Four bounds, and the value clears all four.
* **lower, from the rig — 30.0 s.** The worst continuous legitimate episode at
the shipped threshold across all 2640 runs (weighted family; 20.0 s on
reversals, 0.0 s on flapping).
* **lower, from the firmware — 30 s (`inverter.LINK_STALE_S`).** A unit not
executing its share for less than the firmware's own veto window is a *link*
fault the add-on already answers by dropping it from the split. Reporting
inside that window would be a second, louder alarm about a state already
being handled — the SAFETY-15/SAFETY-19 failure exactly.
* **margin, from inverter B not existing — and it is MEASURED, not felt.** The
campaign's B is A copied, so 30.0 s is two identical machines. Sweeping a
*different* second unit through the weighted family (750 runs each):
| B lag | B ramp | worst legitimate | runs naming |
|---:|---:|---:|---:|
| 10.0 s (A) | 800 W/s (A) | **30.0 s** | 368/750 |
| 20.0 s | 800 W/s | 45.0 s | 469/750 |
| 10.0 s | 400 W/s | **30.0 s** | 370/750 |
| 20.0 s | 400 W/s | 45.0 s | 469/750 |
| 40.0 s | 200 W/s | **90.0 s** | 500/750 |
**The RAMP does not move it at all and the LAG moves it roughly linearly.**
The shortfall lasts as long as the unit's first-order lag holds it more than
`saturation_w` below its share; at these step sizes the ramp is never the
binding term. So the number to carry margin against is B's lag, and a unit
**four times slower than A** produces 90.0 s.
* **upper, from the bill — 900 s**, the quarter that sets the capacity tariff.
**185 s** is thirty-seven control cycles: **6.2×** the measured worst, **2.06×**
the four-times-slower case, **6.2×** the firmware veto and **4.9×** inside the
quarter. ⚠️ Deliberately **not 180** — that is SAFETY-02's site hold, and a
reader must not be able to think this was carried across — and not 150 or 200.
**What it costs, stated rather than hidden:** up to 185 s of a site running
short by one unit's share before anybody is told. Against a fault that ran
6 h 25 m unnoticed, and against a guard that takes **no action**, the window
buys its margin with reporting latency and nothing else.
## 4. It reports. It does not act — and the strongest argument is for acting
Card requirement 3 asks which, and why. The answer is recorded with the
evidence **against** it first, because "alarm-only" is the weaker-looking answer.
**Command it harder — no.** HW-01 settles it: the power stage executed its last
instruction for 6 h 25 m through every command this add-on sent. A command is
not a remedy for a box that is not listening.
**Stop the site, as SAFETY-31 does — no**, and the asymmetry with SAFETY-31 is
exact. Circulating power is stopped because *this controller is one of the two
participants*: removing our half halves the loop and un-hides the residue at a
meter that was netting it to zero. Here this controller is **not** a participant,
nothing is netting, and the sibling is the only thing still delivering. Parking
it takes the site from "short by one unit" to "short by all of them".
**Re-allocate its share to the sibling — no, and NOT because the loop already
does it.** That comfortable answer is false, and the rig says so. Dropping the
wedged unit from `live` — the mechanism `live_units()` already uses for a dead
link — is a real improvement at loads the site cannot cover on one unit's
*share*:
| B wedged, per-unit cap 2000 W | kept in the split | dropped from the split |
|---|---|---|
| load 800 W | settles inside 100 W from cycle 6 | from cycle 8 |
| load 1500 W | **800 W delivered, 700 W error, NEVER inside 100 W in 150 cycles** | 1500 W delivered, inside 100 W from cycle 14 |
| load 2000 W | **800 W delivered, 1200 W error, never settles** | 2000 W delivered, inside 100 W from cycle 8 |
The mechanism is the saturation freeze doing its job on a site comparison that a
non-delivering unit has poisoned: `|site target − site batt|` stays over
`saturation_w` forever, so the target cannot rise and the sibling's *share*
never grows. That is not three cycles of latency; it is the difference between a
site that recovers and one that does not.
**What refuses it anyway** is that the only re-allocation this add-on can
express is **all or nothing**. There is no way to say *"give the sibling the
watts that did not arrive and leave this unit the ones that did"*. And a unit
delivering **part** of its share is short by more than `saturation_w` too, with
nothing here to explain it — so this detector names it, and cannot tell a 50 %
derate from a wedged box, because no instrument this add-on has can:
| B derated to 50 %, load 3000 W, cap 2000 W | site delivers | B contributes | grid error |
|---|---:|---:|---:|
| kept in the split | 2400 W | 800 W | 600 W |
| **dropped** | **2000 W** | **0 W** | **1000 W — 400 W worse** |
**SAFETY-42 names B in that run.** So the remedy would make the site worse, in a
case the detector cannot rule out, on a live control law, driven by a brand-new
alarm. A **partial** re-allocation is what would make acting safe, and the
allocator has no way to express it today. That is a card, and this section is
its evidence.
`test_delivery.py` part 5 asserts the decision as a **counterfactual** rather
than by reading the code: the same fixture run with the report tripping and with
it never tripping produces byte-identical grid, site target, shares and measured
power on every cycle.
## 5. Requirement 4: the saturation freeze is untouched
`control.py` is not modified by this card, at all. The freeze still shapes the
loop; this reports on it. `test_delivery.py` part 6 asserts what each
neighbouring guard concludes on the same fixture — SAFETY-30 per-unit silent,
SAFETY-30 site silent, SAFETY-31 identically zero, the freeze firing at the
same counter on **both** arms — which is the honest version of "every existing
guard is silent", including the one that is not.
## 6. Surviving a restart (SAFETY-34)
`persistent_notification` is **in memory**. Home Assistant restarts, the card is
gone, and the fault runs on — precisely how the 2026-09-02 outage went unseen
for 6 h 25 m with the detector correct throughout. It matters more here than
anywhere else in this add-on, because SAFETY-02 and SAFETY-31 at least leave a
**stopped** site for somebody to notice; this one leaves a site that looks like
it is working.
* durable half: `unit/<name>/delivery_tripped` / `delivery_reason`, namespaced
per unit for `store.unit_key()`'s reason, written **reason first**.
* visible half: `_delivery_announced`, a per-**process** set of unit names,
deliberately not persisted. A restart empties the set and keeps the latch, so
the alarm is re-raised on the first cycle of every process — **one code path**
for a fresh trip and for a restart into an existing one, `check_circulating()`'s
argument.
* **no flat mirror**, where SAFETY-02 has one. `store.py`'s rule 2 keeps the flat
keys so a rollback still finds its latch, and the version being rolled back to
drives exactly one actuator — a roster at which this check is inert.
* one **notification id per unit**, built from the same `unit_slugs()` TEL-07
announces the MQTT entities under. Two wedged inverters must produce two cards,
not one replacing the other.
* it **latches**, and not for SAFETY-02's reason. Nothing here removes its own
evidence, because nothing here changes what is commanded. It latches so that a
unit which stopped delivering for minutes and then resumed cannot erase its own
record — the intermittent fault is the one an operator most needs told about.
Cleared by the same **Start** press as the others.
* the status row is `ok: False, warn: True` — this list's **caution**. Every
other latched row means the site is at 0 W and a red cross is the truth; this
one means the site is still running and quietly short, and a red cross would
say the controller had stopped.
## 7. What this does NOT catch, said plainly
* **Charge shares are not graded.** The account gate needs a bound this
controller reads in the commanded direction. There is one for discharge
(`bms_soc_floor`, required of every roster entry since SAFETY-32) and **none**
for charge. A pack in constant-voltage taper legitimately takes hundreds of
watts less than it is told for tens of minutes, and arming that direction would
fire on the taper of every §12 maintenance charge. The same fault in the other
direction wants the same treatment; what it needs first is a per-pack charge
ceiling this add-on does not have. **That is a card.**
* **Every unit wedged at once is not caught.** The subset is not proper, and
there is no instrument here that separates that from a plant which simply
cannot deliver. The site is short at the meter in that case, which is where the
existing guards can see it.
* **A pack that refuses above its configured floor is reported.** That is
deliberate: either the unit has stopped delivering, or `bms_soc_floor` does not
describe that battery. The notification says both, in those words.
* **A unit with a share of 0 W is not graded**, and neither is it a peer. With
only one unit asked to discharge there is nothing to compare against.
## 8. Mutation ledger