The specification designed per-actuator claims and the implementation dropped
it. So every claim was implicitly about the one and only actuator, resolve()
returned a single scalar Resolution.target_w, and main.py assigned it to a
single self.target. There was no seam an allocator could occupy. This is a
regression fix against the written design, not a new feature.
Why it is worth a P0
ARCH-01 (specs.md §6.2, 2026-09-02) decided the site grid target is allocated across several inverters and never broadcast. The control
primitive is meter emulation: the master reports a fabricated grid reading
(0x056E/0x056F, failsafe.h transcodes it verbatim) and the inverter
regulates itself to it. One number therefore means "the battery moves X W" to
the add-on and "the grid is at X W" to the inverter — an identity that holds
only at N = 1. Broadcast one target to two inverters and each supplies it in
full: the loop gain at the meter becomes N × the per-unit gain, and at the gain config.yaml accepts as its documented maximum that is a permanent ±2 kW limit
cycle with every watchdog in §11.2 reporting healthy.
And the other half, which is what this card actually removes: a per-inverter
reserve filed as a site hi while the peak guard files a site lo is lo > hi → arbiter.py:114-116 → 0 W in the billed quarter. That is the
parked-battery failure SAFETY-25's memo marks "⚠️ fatal if missed".
The split — verified at each construction site, not accepted from the survey
source
scope
why, read off what the claim is ABOUT
loop
site
compute() regulates grid_w, the P1 measurement. §6.2's architecture is one integrator on P1 whose output is then split — this is the claim the allocator exists to divide.
peak
site
the 15-minute average offtake at the grid connection, which is what the capacity tariff bills. One quarter-hour peak per site, not one per pack.
arbitrage
site
buys and sells at the grid against a day-ahead price; the price is a property of the connection and the energy crosses the meter.
safety
site
both construction sites read: stopped is the operator switching control off for the site, inputs missing is the site's own meter gone. Neither is about a pack; both must stop every inverter.
device
unit
ONE inverter's own reported min/max. A second unit has a second rating; there is no site rating.
supervised
unit
max_w caps what may be written to one setpoint entity (§11.3) — a bound on one device, like device, for the same reason.
maintenance
unit
§12's recalibration is a pack procedure: drain THIS pack, charge THIS pack, recalibrate THIS pack's coulomb counter.
failsafe
unit
⚠️the entry that needed reading rather than the survey. SAFETY-02's evidence is site-wide — it compares P1 against standing — but its conclusion is "the unit I am driving is pushing power the wrong way" and its remedy is to park that unit. At N≥2 the same evidence cannot be pinned on a sibling this controller never commanded, so a site-wide stop would park an inverter this watchdog never graded.
The survey's proposed split is confirmed. The reasoning is recorded at the
line — in arbiter.CLAIM_SCOPE and again at each claims.append in main.py.
What was built
Three stages, each a separate function so a test can construct a case at any one
of them:
pre_resolve() — one inverter's own claims → that unit's interval.
Clauses 1 and 2 transposed: the unit's own winning SET gates its own limits.
The SET's value travels beside the interval, not inside it — folding a
−2500 W drain into [-2500, -2500] would turn "clamped to max_w" into "contradicts max_w", a different and wrong answer.
site_resolve() — the shipped arbiter, unchanged. Same code, new
name.
allocate() — the seam, and deliberately not an allocator. At N ≤ 1
it passes the target through (§6.2's identity). At N ≥ 2 it declines — no
shares, allocated=False. ⚠️ The obvious placeholder (hand the target to each
unit) IS the fault ARCH-01 was filed about, and no guard we have can tell it
apart from a real allocator, so a placeholder is worse than none. ALLOC-01
builds this.
What main.py then does is substitute, not abstain — and the earlier
wording here ("refuses to write") overstated the code. It replaces the
resolution with a 0 W failsafe, logs "refusing to broadcast the site
target", and then calls write_setpoint(0.0) on the same line as every
other cycle. The refusal is to BROADCAST; the write still happens, in the
fail-toward-inaction direction. Graded end to end by new part 10h.
The contradiction cannot be written down (AC 4)
The partition is by actuator, and it is a partition — every claim lands in
exactly one stage. A unit's hi and the site's lo are never both terms of one lo > hi test. Constructed and swept: 40 filings (every per-inverter source
× unit × bound, including a unit contradicting itself at lo 900 > hi 100)
against a live site floor, and not one moves the site target or raises a
contradiction. A unit's own impossible pair parks that unit and says so on
its own bounds; it does not park the site. That is SAFETY-25 §0c's table made
mechanical: not guarded against, not expressible.
The same construction filed on one actuator still contradicts, and that is
left exactly as it is — at N = 1 the two really are bounds on one number, and
that is today's behaviour.
At N = 1 this is a pure refactor, and it is proven rather than asserted (AC 3)
With one inverter, actuator_for() routes every source to the site, no
claim is filed on a unit, and stage 2 sees the whole list. All 17 suites
replay unedited above the new ARB-03 sections and are green on the same
numbers; test_arbiter.py additionally runs 13 claim lists through both resolve() and site_resolve() and compares target, winner, binder,
contradiction flag, the considered list and the operator line.
Measured, not assumed: dropping the N = 1 identity takes test_arbiter.py, test_peakguard.py (including the maintenance/peak suppression checks) and test_freeze.py red — because the failsafe and maintenance SET claims
would stop competing for the winner, explain() would name a different source,
and loop_is_following() reads that name.
Verified by breaking it (AC 6) — tools/mutation_run.sh, purged tree
mutation
result
Claim's dataclass default emptied
RED (test_arbiter)
peak reclassified SCOPE_UNIT
RED (arbiter ×4, peakguard ×2)
maintenance reclassified SCOPE_SITE
RED (arbiter ×4, peakguard)
the N=1 identity dropped in actuator_for()
RED (arbiter, peakguard, freeze)
resolve() stops partitioning
RED (arbiter ×4)
allocate() broadcasts at N≥2
RED (arbiter ×2)
stage 1 drops clause-2 gating
RED (arbiter)
⚠️each of the 8 direct claims.append sites routed to a wrong actuator, one at a time
8 mutations, 8 RED — see the table below
resolve() ignores its units argument (if units is None: → if True:)
RED (arbiter part 7, peakguard 10h)
main.py's N≥2 refusal deleted (if not resolution.allocated: → if False:)
RED (peakguard 10h)
SAFETY-25 Option B built as a ninth source
RED (10f both halves, plus 10g)
⚠️One of these started green and that is worth reporting. Emptying the Claim dataclass default survived the first version of the check, because Claim.set() and Claim.limit() pass their own default down and the field's
default never runs. Anything building a Claim(...) directly would have got the
unspecified actuator this card exists to remove. The check now grades the
dataclass constructor as well.
The tripwire is kept and widened, not adapted around
test_peakguard.py part 10f's static AST scan and dynamic SoC sweep are
untouched in substance.source is still Claim.set()'s first positional
argument precisely so the scan keeps reading it — which is why actuator is
the last field of Claim and not the first. specs.md's point is that a
claim NAMES its actuator; the tuple position is not the part that carries
meaning, and moving source off position 0 would silently blind the tripwire
that guards SAFETY-25's deferred option.
One adaptation: the spy gained **kw, so main.py's roster passes through unread rather than being silently dropped (a fixed signature would raise on
every real cycle; a signature that dropped the roster would grade a different
arbitration from the one main.py performs). What is observed — the claim list
— is unchanged, and building Option B as a ninth source still takes it red.
New part 10g pins CLAIM_SCOPE to the same eight-source set (a ninth source
now goes red twice), pins all eight call sites statically (below), and keeps
the real two-inverter cycle as the dynamic half — now labelled with the
three of eight appends it actually reaches.
⚠️ AC 6 was met at 3 of 8 call sites and the check claimed the class — fixed
This is the blocking finding from the review of 06f95e0a, and it was
right. 10g's only call-site check was the real two-inverter cycle, and its own
text read "the line a mis-typed actuator_of(...) at a call site goes red
on". It was not. That cycle is auto, P1-healthy, no maintenance procedure,
no tripped sign watchdog, the guard idle and arbitrage off — it reaches device, supervised and loop and nothing else, three of the eight
direct appends in Controller.cycle(). The reviewer proved the gap on the
shipped tree: actuator_of("maintenance") → actuator_of("peak") at the
maintenance-shaping append left test_arbiter.py, test_peakguard.py and test_arbitrage.pyall green, while at a roster of two it built a site loop
of −1500 W against the guard's site lo of 1500 W and the now-misrouted
charge-only hi = 0 → lo > hi, arbiter clause 3, 0 W in the billed
quarter — SAFETY-25 §0a's fatal case, from a one-word typo. The same mutation
at the failsafe append survived too. scope_violations()does name it at
runtime, but it only logs; nothing graded it.
That is this repository's named failure mode — a check observing something
other than what it claimed — reproduced inside the check written to prevent
it. Closed the way 10f closes it, over the same AST scan: all eight direct
appends expose their actuator as a literal actuator=self.actuator_of('…'), so
the literal is now required to equal the source the claim is filed under,
read as source text rather than evaluated (actuator_of("peak") at the
maintenance append is the same value at the shipped roster of one and a
different string here). All eight, on the line the typo is written, in every
configuration and at every roster. Two further lines stop the scan going vacuous:
every direct append must be readable in that shape, and the ten appends must
still account for 10f's eight sources.
#
append (main.py)
source
mutated to
suite
1
rating limit
device
loop
RED
2
max_w limit
supervised
peak
RED
3
sign-disagreement stop
failsafe
safety
RED
4
control stopped
safety
device
RED
5
inputs missing
safety
maintenance
RED
6
procedure owns setpoint
maintenance
loop
RED
7
charge-only shaping
maintenance
peak
RED
8
grid-following loop
loop
supervised
RED
Eight mutations, eight reds, each on its own purged tree via tools/mutation_run.sh over all 17 files, and every one of them red on the
new line rather than incidentally.
Which sites this does NOT reach, said at the line rather than rounded up.
The two indirect appends — peak_claim and arb_claim — file a claim built
elsewhere in the method and carry no actuator= at the call site to
mis-type. They are pinned where their routing is actually chosen: peakguard.claim() by the SWEEP_SITE sweep (every load × elapsed × battery ×
SoC × floor combination), arbitrage.claim() by test_arbitrage.py. 10g says
so, and says which three of the eight its dynamic cycle sees.
Two more mutations that survived, same class, also closed
resolve() did not have to honour the roster it was given.if units is None: → if True: left the whole suite green. Ignoring the argument makes a
unit that filed nothing this cycle cease to exist — a site of two reads as
a site of one, and stage 3 passes the whole grid target through to the
inverter that spoke: ARCH-01's fault with its own evidence removed. test_arbiter.py part 7 now grades it on the one case that separates the two,
a silent unit. Mutation RED in arbiter and peakguard.
The N≥2 refusal was unreachable and ungraded.if not resolution.allocated: → if False: left the whole suite green, so the right
instinct was one refactor away from being deleted as dead code. The guard is
kept — the alternative default is the ARCH-01 fault — and new part 10h
grades it on the two-inverter cycle that already existed: the 0 W, the failsafe winner, the log line naming the roster size, and the write that
follows it. Mutation RED.
SAFETY-24 (AC 5)
The combination sweep is re-run and holds: 0 negative floors, hi still +inf on every claim the guard files. It now also asserts every one of them
is on the SITE actuator — the other half of the same asymmetry now that a
claim has an actuator, because a floor bound to one inverter's share would leave
the site (the thing the capacity tariff meters) with no floor at all.
Things I am unsure about, or that a reviewer should push on
The 19,800 figure. SAFETY-25's memo §3 says a reviewer proved the lo-only
asymmetry "across 19,800 combinations". The sweep actually in the tree
(test_peakguard.py part 8) prints 1,296 — 6 loads × 3 elapsed × 4
battery × 6 SoC × 3 floor. I re-ran the shipped proof and it holds, and I
report the number it prints rather than the memo's. I did not inflate the
sweep to reach 19,800: I could not find where that figure comes from, and
inventing dimensions to match a number in a memo is the wrong direction. If
the memo's figure refers to a sweep that was never committed, that is a
separate finding and probably a separate card. It is now SAFETY-33.
The allocated=False refusal in main.py is arguably scope creep. RESOLVED. It was unreachable and ungraded — if False: left the suite
green. The reviewer's call was to keep it (the alternative default is the
ARCH-01 fault) and grade it, which part 10h now does. What it does is also
now stated accurately: it substitutes a 0 W failsafe and writes it, it does
not refuse to write.
UnitBounds carries gate and pinned_w, which is more than "an
interval". They are there because clause 2 gates a limit on the winning
set's priority, and at N ≥ 2 the winner is chosen at stage 2 — after stage 1.
Stage 1 states the constraint and records what it is gated on; deciding
whether it applies is stage 3's business. Guessing at stage 1 is how a
maintenance charge-only hi would come to bind a peak discharge it does not
outrank. If ALLOC-01 wants a plainer interval it can narrow this.
resolve(claims, units=None) derives the roster from the claims when it is
not given. That is right for a caller that files a claim for every unit
every cycle (main.py does — supervised is unconditional) and wrong for one
that does not. main.py passes the real roster; the default is documented as
a convenience, not a contract. ⚠️And the argument now has to be honoured, which is not the same as existing — see the mutation above.
What is NOT restored: the spec's owner, setpoint_w and expires_at. owner is source under another name; expires_at is deliberately absent
because claims in this implementation are re-filed every cycle and a
sticky claim is the failure main.py's comments already warn about twice. I
did not want to smuggle a lifetime model in under a card about the actuator.
10f's dynamic half still cannot express two packs, exactly as SAFETY-25
§0d says. 10g's two-inverter cycle is a routing check, not a two-pack SoC
fixture; whoever reaches the ALLOC-01 revisit still inherits that job.
The static call-site pin reads actuator_of()'s argument, not its
answer. It is deliberately textual — that is the only thing that separates
two routings which are the same value at N = 1 — so it grades what is
written, and the routing table itself is what 10g's other halves grade. An
append that computed its actuator would take the readability line red rather
than be skipped, which is the failure shape I was most worried about
re-introducing.
No config key, no option, no operator surface, no default moved. Inverter B
does not exist yet and nothing here measures it.
Full suite: 17/17 green in goodwe-addon-ci. Mutations this round: 10
run, 10 red — the eight call sites above, plus if units is None: → if True: and if not resolution.allocated: → if False: — each on its own purged
tree via tools/mutation_run.sh over all 17 files.
## The defect
`specs.md:467` declares `actuator: str` as the **first field of `Claim`**. The
shipped `app/arbiter.py:55` had no such field:
```
shipped: Claim(source, priority, kind, value, lo, hi, reason)
specified: Claim(actuator, owner, setpoint_w, reason, priority, expires_at)
```
The specification designed per-actuator claims and the implementation dropped
it. So every claim was implicitly about *the one and only actuator*, `resolve()`
returned a single scalar `Resolution.target_w`, and `main.py` assigned it to a
single `self.target`. **There was no seam an allocator could occupy.** This is a
regression fix against the written design, not a new feature.
## Why it is worth a P0
`ARCH-01` (specs.md §6.2, 2026-09-02) decided the site grid target is
**allocated** across several inverters and never broadcast. The control
primitive is meter emulation: the master reports a fabricated grid reading
(`0x056E`/`0x056F`, `failsafe.h` transcodes it verbatim) and the inverter
regulates itself to it. One number therefore means *"the battery moves X W"* to
the add-on and *"the grid is at X W"* to the inverter — **an identity that holds
only at N = 1.** Broadcast one target to two inverters and each supplies it in
full: the loop gain at the meter becomes N × the per-unit gain, and at the gain
`config.yaml` accepts as its documented maximum that is a permanent ±2 kW limit
cycle *with every watchdog in §11.2 reporting healthy.*
And the other half, which is what this card actually removes: a per-inverter
reserve filed as a **site `hi`** while the peak guard files a **site `lo`** is
`lo > hi` → `arbiter.py:114-116` → **0 W in the billed quarter.** That is the
parked-battery failure `SAFETY-25`'s memo marks *"⚠️ fatal if missed"*.
## The split — verified at each construction site, not accepted from the survey
| source | scope | why, read off what the claim is ABOUT |
|---|---|---|
| `loop` | **site** | `compute()` regulates `grid_w`, the P1 measurement. §6.2's architecture is one integrator on P1 whose output is then split — this is the claim the allocator exists to divide. |
| `peak` | **site** | the 15-minute average offtake **at the grid connection**, which is what the capacity tariff bills. One quarter-hour peak per site, not one per pack. |
| `arbitrage` | **site** | buys and sells at the grid against a day-ahead price; the price is a property of the connection and the energy crosses the meter. |
| `safety` | **site** | both construction sites read: `stopped` is the operator switching control off for the site, `inputs missing` is the site's own meter gone. Neither is about a pack; both must stop every inverter. |
| `device` | **unit** | ONE inverter's own reported min/max. A second unit has a second rating; there is no site rating. |
| `supervised` | **unit** | `max_w` caps what may be **written** to one setpoint entity (§11.3) — a bound on one device, like `device`, for the same reason. |
| `maintenance` | **unit** | §12's recalibration is a **pack** procedure: drain THIS pack, charge THIS pack, recalibrate THIS pack's coulomb counter. |
| `failsafe` | **unit** | ⚠️ **the entry that needed reading rather than the survey.** SAFETY-02's *evidence* is site-wide — it compares P1 against `standing` — but its *conclusion* is "the unit I am driving is pushing power the wrong way" and its remedy is to park that unit. At N≥2 the same evidence cannot be pinned on a sibling this controller never commanded, so a site-wide stop would park an inverter this watchdog never graded. |
The survey's proposed split is confirmed. The reasoning is recorded **at the
line** — in `arbiter.CLAIM_SCOPE` and again at each `claims.append` in
`main.py`.
## What was built
Three stages, each a separate function so a test can construct a case at any one
of them:
1. **`pre_resolve()`** — one inverter's own claims → that unit's interval.
Clauses 1 and 2 transposed: the unit's own winning SET gates its own limits.
The SET's **value travels beside the interval, not inside it** — folding a
−2500 W drain into `[-2500, -2500]` would turn *"clamped to max_w"* into
*"contradicts max_w"*, a different and wrong answer.
2. **`site_resolve()`** — **the shipped arbiter, unchanged.** Same code, new
name.
3. **`allocate()`** — **the seam, and deliberately not an allocator.** At N ≤ 1
it passes the target through (§6.2's identity). At N ≥ 2 it **declines** — no
shares, `allocated=False`. ⚠️ The obvious placeholder (hand the target to each
unit) IS the fault ARCH-01 was filed about, and no guard we have can tell it
apart from a real allocator, so a placeholder is worse than none. **ALLOC-01
builds this.**
What `main.py` then does is **substitute, not abstain** — and the earlier
wording here ("refuses to write") overstated the code. It replaces the
resolution with a **0 W `failsafe`**, logs *"refusing to broadcast the site
target"*, and then calls `write_setpoint(0.0)` on the same line as every
other cycle. The refusal is to BROADCAST; the write still happens, in the
fail-toward-inaction direction. Graded end to end by new **part 10h**.
## The contradiction cannot be written down (AC 4)
The partition is **by `actuator`, and it is a partition** — every claim lands in
exactly one stage. A unit's `hi` and the site's `lo` are never both terms of one
`lo > hi` test. Constructed and swept: **40 filings** (every per-inverter source
× unit × bound, *including a unit contradicting itself at `lo 900 > hi 100`*)
against a live site floor, and not one moves the site target or raises a
contradiction. A unit's own impossible pair parks **that unit** and says so on
its own bounds; it does not park the site. That is SAFETY-25 §0c's table made
mechanical: not *guarded against*, **not expressible.**
The same construction filed on **one** actuator still contradicts, and that is
left exactly as it is — at N = 1 the two really are bounds on one number, and
that is today's behaviour.
## At N = 1 this is a pure refactor, and it is proven rather than asserted (AC 3)
With one inverter, `actuator_for()` routes **every** source to the site, no
claim is filed on a unit, and stage 2 sees the whole list. All **17 suites
replay unedited** above the new ARB-03 sections and are green on the same
numbers; `test_arbiter.py` additionally runs 13 claim lists through **both**
`resolve()` and `site_resolve()` and compares target, winner, binder,
contradiction flag, the considered list and the operator line.
Measured, not assumed: **dropping the N = 1 identity takes `test_arbiter.py`,
`test_peakguard.py` (including the maintenance/peak suppression checks) and
`test_freeze.py` red** — because the `failsafe` and `maintenance` SET claims
would stop competing for the winner, `explain()` would name a different source,
and `loop_is_following()` reads that name.
## Verified by breaking it (AC 6) — `tools/mutation_run.sh`, purged tree
| mutation | result |
|---|---|
| `Claim`'s dataclass default emptied | **RED** (test_arbiter) |
| `peak` reclassified `SCOPE_UNIT` | **RED** (arbiter ×4, peakguard ×2) |
| `maintenance` reclassified `SCOPE_SITE` | **RED** (arbiter ×4, peakguard) |
| the N=1 identity dropped in `actuator_for()` | **RED** (arbiter, peakguard, freeze) |
| `resolve()` stops partitioning | **RED** (arbiter ×4) |
| `allocate()` broadcasts at N≥2 | **RED** (arbiter ×2) |
| stage 1 drops clause-2 gating | **RED** (arbiter) |
| ⚠️ **each of the 8 direct `claims.append` sites routed to a wrong actuator, one at a time** | **8 mutations, 8 RED** — see the table below |
| `resolve()` ignores its `units` argument (`if units is None:` → `if True:`) | **RED** (arbiter part 7, peakguard 10h) |
| `main.py`'s N≥2 refusal deleted (`if not resolution.allocated:` → `if False:`) | **RED** (peakguard 10h) |
| SAFETY-25 **Option B built as a ninth source** | **RED** (10f both halves, plus 10g) |
⚠️ **One of these started green and that is worth reporting.** Emptying the
`Claim` dataclass default *survived* the first version of the check, because
`Claim.set()` and `Claim.limit()` pass their own default down and the field's
default never runs. Anything building a `Claim(...)` directly would have got the
unspecified actuator this card exists to remove. The check now grades the
dataclass constructor as well.
## The tripwire is kept and widened, not adapted around
`test_peakguard.py` part 10f's **static AST scan and dynamic SoC sweep are
untouched in substance.** `source` is still `Claim.set()`'s first positional
argument *precisely so the scan keeps reading it* — which is why `actuator` is
the **last** field of `Claim` and not the first. specs.md's point is that a
claim NAMES its actuator; the tuple position is not the part that carries
meaning, and moving `source` off position 0 would silently blind the tripwire
that guards SAFETY-25's deferred option.
One adaptation: the spy gained `**kw`, so `main.py`'s roster passes through
**unread** rather than being silently dropped (a fixed signature would raise on
every real cycle; a signature that dropped the roster would grade a different
arbitration from the one `main.py` performs). What is observed — the claim list
— is unchanged, and building Option B as a ninth source still takes it red.
New **part 10g** pins `CLAIM_SCOPE` to the same eight-source set (a ninth source
now goes red twice), pins **all eight call sites statically** (below), and keeps
the **real two-inverter cycle** as the dynamic half — now labelled with the
three of eight appends it actually reaches.
## ⚠️ AC 6 was met at 3 of 8 call sites and the check claimed the class — fixed
**This is the blocking finding from the review of `06f95e0a`, and it was
right.** 10g's only call-site check was the real two-inverter cycle, and its own
text read *"the line a mis-typed `actuator_of(...)` at a call site goes red
on"*. **It was not.** That cycle is auto, P1-healthy, no maintenance procedure,
no tripped sign watchdog, the guard idle and arbitrage off — it reaches
`device`, `supervised` and `loop` and **nothing else**, three of the eight
direct appends in `Controller.cycle()`. The reviewer proved the gap on the
shipped tree: `actuator_of("maintenance")` → `actuator_of("peak")` at the
maintenance-shaping append left `test_arbiter.py`, `test_peakguard.py` and
`test_arbitrage.py` **all green**, while at a roster of two it built a site loop
of −1500 W against the guard's site `lo` of 1500 W and the now-misrouted
charge-only `hi = 0` → `lo > hi`, arbiter clause 3, **0 W in the billed
quarter** — SAFETY-25 §0a's fatal case, from a one-word typo. The same mutation
at the `failsafe` append survived too. `scope_violations()` *does* name it at
runtime, but it only logs; nothing graded it.
That is this repository's named failure mode — **a check observing something
other than what it claimed** — reproduced inside the check written to prevent
it. Closed the way 10f closes it, over the **same AST scan**: all eight direct
appends expose their actuator as a literal `actuator=self.actuator_of('…')`, so
the literal is now required to **equal the source the claim is filed under**,
read as *source text* rather than evaluated (`actuator_of("peak")` at the
maintenance append is the same **value** at the shipped roster of one and a
different **string** here). All eight, on the line the typo is written, in every
configuration and at every roster. Two further lines stop the scan going vacuous:
every direct append must be readable in that shape, and the ten appends must
still account for 10f's eight sources.
| # | append (`main.py`) | source | mutated to | suite |
|---|---|---|---|---|
| 1 | rating limit | `device` | `loop` | **RED** |
| 2 | `max_w` limit | `supervised` | `peak` | **RED** |
| 3 | sign-disagreement stop | `failsafe` | `safety` | **RED** |
| 4 | control stopped | `safety` | `device` | **RED** |
| 5 | inputs missing | `safety` | `maintenance` | **RED** |
| 6 | procedure owns setpoint | `maintenance` | `loop` | **RED** |
| 7 | charge-only shaping | `maintenance` | `peak` | **RED** |
| 8 | grid-following loop | `loop` | `supervised` | **RED** |
Eight mutations, eight reds, each on its own purged tree via
`tools/mutation_run.sh` over all 17 files, and **every one of them red on the
new line** rather than incidentally.
**Which sites this does NOT reach, said at the line rather than rounded up.**
The two *indirect* appends — `peak_claim` and `arb_claim` — file a claim built
elsewhere in the method and carry **no `actuator=` at the call site to
mis-type**. They are pinned where their routing is actually chosen:
`peakguard.claim()` by the `SWEEP_SITE` sweep (every load × elapsed × battery ×
SoC × floor combination), `arbitrage.claim()` by `test_arbitrage.py`. 10g says
so, and says which three of the eight its dynamic cycle sees.
## Two more mutations that survived, same class, also closed
* **`resolve()` did not have to honour the roster it was given.** `if units is
None:` → `if True:` left the whole suite green. Ignoring the argument makes a
unit that filed nothing this cycle **cease to exist** — a site of two reads as
a site of one, and stage 3 passes the whole grid target through to the
inverter that spoke: ARCH-01's fault with its own evidence removed.
`test_arbiter.py` part 7 now grades it on the one case that separates the two,
a **silent unit**. Mutation **RED** in arbiter and peakguard.
* **The N≥2 refusal was unreachable and ungraded.** `if not
resolution.allocated:` → `if False:` left the whole suite green, so the right
instinct was one refactor away from being deleted as dead code. **The guard is
kept** — the alternative default is the ARCH-01 fault — and new **part 10h**
grades it on the two-inverter cycle that already existed: the 0 W, the
`failsafe` winner, the log line naming the roster size, and **the write that
follows it**. Mutation **RED**.
## SAFETY-24 (AC 5)
The combination sweep is re-run and holds: **0 negative floors**, `hi` still
`+inf` on every claim the guard files. It now also asserts **every one of them
is on the SITE actuator** — the other half of the same asymmetry now that a
claim has an actuator, because a floor bound to one inverter's share would leave
the site (the thing the capacity tariff meters) with **no floor at all**.
## Things I am unsure about, or that a reviewer should push on
1. **The 19,800 figure.** SAFETY-25's memo §3 says a reviewer proved the `lo`-only
asymmetry "across 19,800 combinations". The sweep actually in the tree
(`test_peakguard.py` part 8) prints **1,296** — 6 loads × 3 elapsed × 4
battery × 6 SoC × 3 floor. I re-ran the shipped proof and it holds, and I
report the number it prints rather than the memo's. I did **not** inflate the
sweep to reach 19,800: I could not find where that figure comes from, and
inventing dimensions to match a number in a memo is the wrong direction. If
the memo's figure refers to a sweep that was never committed, that is a
separate finding and probably a separate card. **It is now `SAFETY-33`.**
2. ~~**The `allocated=False` refusal in `main.py` is arguably scope creep.**~~
**RESOLVED.** It was unreachable *and ungraded* — `if False:` left the suite
green. The reviewer's call was to **keep** it (the alternative default is the
ARCH-01 fault) and grade it, which part 10h now does. What it does is also
now stated accurately: it substitutes a 0 W failsafe and writes it, it does
not refuse to write.
3. **`UnitBounds` carries `gate` and `pinned_w`, which is more than "an
interval".** They are there because clause 2 gates a limit on the *winning*
set's priority, and at N ≥ 2 the winner is chosen at stage 2 — after stage 1.
Stage 1 states the constraint and records what it is gated on; deciding
whether it applies is stage 3's business. Guessing at stage 1 is how a
maintenance charge-only `hi` would come to bind a peak discharge it does not
outrank. If ALLOC-01 wants a plainer interval it can narrow this.
4. **`resolve(claims, units=None)` derives the roster from the claims when it is
not given.** That is right for a caller that files a claim for every unit
every cycle (`main.py` does — `supervised` is unconditional) and wrong for one
that does not. `main.py` passes the real roster; the default is documented as
a convenience, not a contract. ⚠️ **And the argument now has to be
*honoured*, which is not the same as existing** — see the mutation above.
5. **What is NOT restored:** the spec's `owner`, `setpoint_w` and `expires_at`.
`owner` is `source` under another name; `expires_at` is deliberately absent
because claims in this implementation are re-filed **every cycle** and a
sticky claim is the failure `main.py`'s comments already warn about twice. I
did not want to smuggle a lifetime model in under a card about the actuator.
6. **10f's dynamic half still cannot express two packs**, exactly as SAFETY-25
§0d says. 10g's two-inverter cycle is a routing check, not a two-pack SoC
fixture; whoever reaches the ALLOC-01 revisit still inherits that job.
7. **The static call-site pin reads `actuator_of()`'s argument, not its
answer.** It is deliberately textual — that is the only thing that separates
two routings which are the same value at N = 1 — so it grades *what is
written*, and the routing table itself is what 10g's other halves grade. An
append that computed its actuator would take the readability line red rather
than be skipped, which is the failure shape I was most worried about
re-introducing.
No config key, no option, no operator surface, no default moved. **Inverter B
does not exist yet and nothing here measures it.**
Full suite: **17/17 green** in `goodwe-addon-ci`. Mutations this round: **10
run, 10 red** — the eight call sites above, plus `if units is None:` → `if
True:` and `if not resolution.allocated:` → `if False:` — each on its own purged
tree via `tools/mutation_run.sh` over all 17 files.
specs.md:467 declares `actuator: str` as the FIRST field of `Claim`.
app/arbiter.py:55 had no such field. The specification designed
per-actuator claims and the implementation dropped it, so every claim was
implicitly about "the one and only actuator", resolve() returned one scalar
Resolution.target_w, and main.py assigned it to one self.target. THERE WAS
NO SEAM AN ALLOCATOR COULD OCCUPY - and ARCH-01 (specs.md §6.2, 2026-09-02)
decided the site grid target is ALLOCATED across several inverters and never
broadcast. This is a regression fix against the written design; creating that
seam is the whole of the change.
WHY IT MATTERS, IN THE UNITS THE SYSTEM IS BILLED IN. The control primitive
is meter emulation: the master reports a fabricated grid reading and the
inverter regulates itself to it, so one number means "the battery moves X W"
to the add-on and "the grid is at X W" to the inverter - an identity that
holds only at N=1. Broadcast one target to two inverters and each supplies it
in full; the loop gain at the meter becomes N times the per-unit gain, which
at the gain config.yaml:374 accepts is a permanent limit cycle with every
watchdog in §11.2 reporting healthy. And a per-inverter reserve filed as a
site `hi` against the peak guard's site `lo` is `lo > hi`, arbiter clause 3,
0 W IN THE BILLED QUARTER - the parked battery SAFETY-25's memo calls fatal.
THE SPLIT, VERIFIED AT EACH CONSTRUCTION SITE RATHER THAN ACCEPTED FROM THE
SURVEY. Four sources are about the GRID CONNECTION and are SITE claims:
`loop` (compute() regulates grid_w, the P1 measurement, and §6.2's whole
architecture is one integrator on P1 whose output is then split), `peak` (the
15-minute average offtake AT the grid connection, which is what the capacity
tariff bills - one quarter-hour peak per site, not one per pack),
`arbitrage` (buys and sells at the grid against a day-ahead price; the price
is a property of the connection and the energy crosses the meter) and
`safety` (both construction sites read: "stopped" is the operator switching
control off for the site, "inputs missing" is the site's own meter gone -
neither is about a pack, and both must stop every inverter).
Four are about a pack, a device or one write and are PER-INVERTER: `device`
(one inverter's own reported min/max - there is no site rating),
`supervised` (max_w caps what may be WRITTEN to one setpoint entity, §11.3,
so it is scoped like `device` for the same reason), `maintenance` (§12's
recalibration is a pack procedure - drain THIS pack, charge THIS pack,
recalibrate THIS pack's counter) and `failsafe`. `failsafe` is the entry that
needed the reading rather than the survey: SAFETY-02's EVIDENCE is site-wide
(it compares P1 against the standing command) but its CONCLUSION is
"the unit I am driving is pushing power the wrong way" and its remedy is to
park that unit. At N>=2 the same evidence cannot be attributed to a sibling
this controller never commanded, so a site-wide stop would park an inverter
this watchdog never graded. The reasoning for all eight is recorded AT THE
LINE, in arbiter.CLAIM_SCOPE and again at each claims.append in main.py.
WHAT WAS BUILT. resolve() is three stages, each a separate function so a test
can construct a case at any one of them:
pre_resolve() - one inverter's own claims -> that unit's interval. Clauses
1 and 2 transposed: the unit's own winning SET gates its
own limits. The SET's value travels BESIDE the interval,
not inside it - folding a -2500 W drain into
[-2500, -2500] would turn "clamped to max_w" into
"contradicts max_w", a different and wrong answer.
site_resolve() - THE SHIPPED ARBITER, UNCHANGED. Same code, new name.
allocate() - THE SEAM, AND DELIBERATELY NOT AN ALLOCATOR. ALLOC-01
builds it. At N<=1 it passes the target through (§6.2's
identity). At N>=2 it DECLINES - no shares,
allocated=False - and main.py refuses to write rather than
broadcast, because the obvious placeholder (hand the target
to each) is the fault ARCH-01 was filed about and no guard
we have can tell it apart from the real thing.
THE CONTRADICTION IS NOT GUARDED AGAINST, IT CANNOT BE WRITTEN DOWN. The
partition is by `actuator` and it is a partition: a unit's `hi` and the site's
`lo` are never both terms of one `lo > hi` test. Constructed and swept - 40
filings (every per-inverter source x unit x bound, including a unit
contradicting ITSELF at lo 900 > hi 100) against a live site floor, and not
one moves the site target or raises a contradiction. A unit's own impossible
pair parks that unit and says so on its own bounds; it does not park the site.
That is SAFETY-25 §0c's table made mechanical.
AT N=1 THIS IS A PURE REFACTOR, AND THAT IS PROVEN RATHER THAN ASSERTED. With
one inverter the fabricated grid reading and the battery setpoint are the same
number, so the grid connection and the only unit ARE one actuator:
actuator_for() routes every source to the site, no claim is filed on a unit,
stage 2 sees the whole list and answers what the shipped arbiter answered.
All 17 suites replay UNEDITED above the ARB-03 sections and are green on the
same numbers; test_arbiter.py additionally runs 13 claim lists through both
resolve() and site_resolve() and compares target, winner, binder,
contradiction flag, the considered list and the operator line.
EVIDENCE, BY BREAKING EACH THING (tools/mutation_run.sh, purged tree):
* Claim's dataclass default emptied -> RED (test_arbiter)
...and it survived until the check was added: the two helpers pass their
own default down, so the FIELD's default never ran. Now checked directly.
* `peak` reclassified SCOPE_UNIT -> RED (arbiter x4, peakguard x2)
* `maintenance` reclassified SCOPE_SITE -> RED (arbiter x4, peakguard)
* the N=1 identity dropped in actuator_for() -> RED (arbiter, peakguard
incl. the maintenance/peak suppression checks, freeze) - which is the
measurement that says splitting at N=1 would be a behaviour change
* resolve() stops partitioning -> RED (arbiter x4)
* allocate() broadcasts at N>=2 -> RED (arbiter x2)
* stage 1 drops clause-2 gating -> RED (arbiter)
* a call site routes `device` through `loop` -> RED (peakguard 10g)
* SAFETY-25 Option B built as a ninth source -> RED (peakguard 10f, both
halves, plus 10g) - the tripwire still bites
SAFETY-24's asymmetry proof is re-run and holds: 0 negative floors over the
shipped sweep, `hi` still +inf on every claim the guard files, and it now also
asserts every one of them is on the SITE actuator - a floor bound to one
inverter's share would leave the site, the thing the capacity tariff meters,
with no floor at all.
test_peakguard.py part 10f is kept, not adapted around. `source` is still
Claim.set()'s first positional argument precisely so its AST scan keeps
reading it, which is why `actuator` is the LAST field of Claim and not the
first - moving `source` off position 0 would silently blind the tripwire that
guards SAFETY-25's deferred option. The one adaptation is `**kw` on its spy,
so main.py's roster passes through unread rather than being silently dropped.
New part 10g pins CLAIM_SCOPE to the same eight-source set and grades a REAL
two-inverter cycle, which is the only place a mis-typed actuator_of(...) at a
call site is observable - at N=1 every source legitimately routes to the site,
so a swap there is invisible by construction.
No config key, no option, no operator surface, no default moved. Inverter B
does not exist yet and nothing here measures it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
The independent review of PR #40 found AC 6 met at three of the eight claim
construction sites while the check claimed to cover the class - this
repository's named failure mode, a check observing something other than what
it claimed, reproduced inside the check written to prevent it.
WHAT WAS WRONG. Part 10g's only call-site check was a real two-inverter cycle.
That cycle is auto, P1-healthy, no maintenance procedure, no tripped sign
watchdog, the guard idle and arbitrage off, so it reaches `device`,
`supervised` and `loop` and nothing else - three of the eight direct appends in
`Controller.cycle()`. Its own check text nevertheless read "the line a
mis-typed `actuator_of(...)` at a call site goes red on", and the PR body's
mutation table presented it as covering the class. The reviewer proved the gap
on the shipped tree: `actuator_of("maintenance")` -> `actuator_of("peak")` at
the maintenance-shaping append left test_arbiter.py, test_peakguard.py and
test_arbitrage.py ALL GREEN, while at a roster of two it built a site loop of
-1500 W against the guard's site `lo` of 1500 W and the now-misrouted
charge-only `hi = 0` - `lo > hi`, arbiter clause 3, 0 W IN THE BILLED QUARTER,
SAFETY-25 §0a's fatal case from a one-word typo. The same mutation at the
`failsafe` append survived too. The runtime detector does fire -
`scope_violations()` names it - but it only logs; nothing graded it.
THE FIX, over machinery already present. Part 10f's AST scan already reads each
`claims.append(...)` in `Controller.cycle()`. All eight direct appends expose
their actuator statically as `actuator=self.actuator_of('<literal>')`, so 10g
now requires that literal to EQUAL the source the claim is filed under, read as
SOURCE TEXT rather than evaluated - `actuator_of("peak")` at the maintenance
append is the same VALUE at the shipped roster of one and a different string
here. That pins all eight routing sites, on the line the typo is written, in
every configuration and at every roster, exactly the way 10f pins the source
set. Two further lines keep the scan from going vacuous: every direct append
must be readable in that shape (a computed or hand-written actuator fails
there), and the ten appends must still account for 10f's eight sources.
The two INDIRECT appends carry no `actuator=` at the call site to mis-type.
`peak_claim` and `arb_claim` are routed where their claim is built, and are
pinned there - `peakguard.claim()` by the SWEEP_SITE sweep over every load x
elapsed x battery x SoC x floor combination, `arbitrage.claim()` by
test_arbitrage.py. 10g now says so at the line, and says which three of the
eight its dynamic cycle actually sees, rather than rounding up.
MEASURED, NOT ARGUED. Each of the eight direct appends mutated in turn to a
wrong actuator - device->loop, supervised->peak, failsafe->safety,
safety(stopped)->device, safety(inputs missing)->maintenance,
maintenance(SET)->loop, maintenance(shaping)->peak, loop->supervised - each on
a purged tree via tools/mutation_run.sh over all 17 files. All eight RED, every
one of them on the new line.
TWO SURVIVING MUTATIONS OF THE SAME CLASS, ALSO CLOSED.
* `resolve()` did not have to honour the roster it was given: `if units is
None:` -> `if True:` left the whole suite green. Ignoring the argument makes a
unit that filed nothing this cycle cease to exist - a site of two reads as a
site of one and stage 3 passes the whole grid target through to the inverter
that spoke, which is ARCH-01's fault with its own evidence removed.
test_arbiter.py part 7 now grades it on the one case that separates the two, a
SILENT unit. Mutation RED in test_arbiter.py and test_peakguard.py.
* main.py's N>=2 refusal was unreachable and ungraded: `if not
resolution.allocated:` -> `if False:` left the whole suite green, so the right
instinct was one refactor away from being deleted as dead code. The guard is
KEPT - the alternative default is the ARCH-01 fault - and new part 10h grades
it on the two-inverter cycle that already existed: the 0 W, the `failsafe`
winner, the log line naming the roster size, and the write that follows.
Mutation RED.
AND THE WORDING CORRECTED. main.py does not "refuse to write" on
`allocated=False`. It SUBSTITUTES a 0 W failsafe and then calls
`write_setpoint(0.0)` like any other cycle. 0 W is the correct
fail-toward-inaction direction and the log text is accurate about what it
refuses - to BROADCAST the site target - but "refuses to write" overstates the
code. Said as what it does, in the CHANGELOG, at the branch in main.py, and in
10h's own check text. `resolve()`'s docstring now says the roster argument has
to be HONOURED, which is not the same as existing.
17/17 green on a purged tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
admin
merged commit df88b323f5 into release/1.02026-09-02 04:16:45 +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.
The defect
specs.md:467declaresactuator: stras the first field ofClaim. Theshipped
app/arbiter.py:55had no such field:The specification designed per-actuator claims and the implementation dropped
it. So every claim was implicitly about the one and only actuator,
resolve()returned a single scalar
Resolution.target_w, andmain.pyassigned it to asingle
self.target. There was no seam an allocator could occupy. This is aregression fix against the written design, not a new feature.
Why it is worth a P0
ARCH-01(specs.md §6.2, 2026-09-02) decided the site grid target isallocated across several inverters and never broadcast. The control
primitive is meter emulation: the master reports a fabricated grid reading
(
0x056E/0x056F,failsafe.htranscodes it verbatim) and the inverterregulates itself to it. One number therefore means "the battery moves X W" to
the add-on and "the grid is at X W" to the inverter — an identity that holds
only at N = 1. Broadcast one target to two inverters and each supplies it in
full: the loop gain at the meter becomes N × the per-unit gain, and at the gain
config.yamlaccepts as its documented maximum that is a permanent ±2 kW limitcycle with every watchdog in §11.2 reporting healthy.
And the other half, which is what this card actually removes: a per-inverter
reserve filed as a site
hiwhile the peak guard files a siteloislo > hi→arbiter.py:114-116→ 0 W in the billed quarter. That is theparked-battery failure
SAFETY-25's memo marks "⚠️ fatal if missed".The split — verified at each construction site, not accepted from the survey
loopcompute()regulatesgrid_w, the P1 measurement. §6.2's architecture is one integrator on P1 whose output is then split — this is the claim the allocator exists to divide.peakarbitragesafetystoppedis the operator switching control off for the site,inputs missingis the site's own meter gone. Neither is about a pack; both must stop every inverter.devicesupervisedmax_wcaps what may be written to one setpoint entity (§11.3) — a bound on one device, likedevice, for the same reason.maintenancefailsafestanding— but its conclusion is "the unit I am driving is pushing power the wrong way" and its remedy is to park that unit. At N≥2 the same evidence cannot be pinned on a sibling this controller never commanded, so a site-wide stop would park an inverter this watchdog never graded.The survey's proposed split is confirmed. The reasoning is recorded at the
line — in
arbiter.CLAIM_SCOPEand again at eachclaims.appendinmain.py.What was built
Three stages, each a separate function so a test can construct a case at any one
of them:
pre_resolve()— one inverter's own claims → that unit's interval.Clauses 1 and 2 transposed: the unit's own winning SET gates its own limits.
The SET's value travels beside the interval, not inside it — folding a
−2500 W drain into
[-2500, -2500]would turn "clamped to max_w" into"contradicts max_w", a different and wrong answer.
site_resolve()— the shipped arbiter, unchanged. Same code, newname.
allocate()— the seam, and deliberately not an allocator. At N ≤ 1it passes the target through (§6.2's identity). At N ≥ 2 it declines — no
shares,
allocated=False. ⚠️ The obvious placeholder (hand the target to eachunit) IS the fault ARCH-01 was filed about, and no guard we have can tell it
apart from a real allocator, so a placeholder is worse than none. ALLOC-01
builds this.
What
main.pythen does is substitute, not abstain — and the earlierwording here ("refuses to write") overstated the code. It replaces the
resolution with a 0 W
failsafe, logs "refusing to broadcast the sitetarget", and then calls
write_setpoint(0.0)on the same line as everyother cycle. The refusal is to BROADCAST; the write still happens, in the
fail-toward-inaction direction. Graded end to end by new part 10h.
The contradiction cannot be written down (AC 4)
The partition is by
actuator, and it is a partition — every claim lands inexactly one stage. A unit's
hiand the site'sloare never both terms of onelo > hitest. Constructed and swept: 40 filings (every per-inverter source× unit × bound, including a unit contradicting itself at
lo 900 > hi 100)against a live site floor, and not one moves the site target or raises a
contradiction. A unit's own impossible pair parks that unit and says so on
its own bounds; it does not park the site. That is SAFETY-25 §0c's table made
mechanical: not guarded against, not expressible.
The same construction filed on one actuator still contradicts, and that is
left exactly as it is — at N = 1 the two really are bounds on one number, and
that is today's behaviour.
At N = 1 this is a pure refactor, and it is proven rather than asserted (AC 3)
With one inverter,
actuator_for()routes every source to the site, noclaim is filed on a unit, and stage 2 sees the whole list. All 17 suites
replay unedited above the new ARB-03 sections and are green on the same
numbers;
test_arbiter.pyadditionally runs 13 claim lists through bothresolve()andsite_resolve()and compares target, winner, binder,contradiction flag, the considered list and the operator line.
Measured, not assumed: dropping the N = 1 identity takes
test_arbiter.py,test_peakguard.py(including the maintenance/peak suppression checks) andtest_freeze.pyred — because thefailsafeandmaintenanceSET claimswould stop competing for the winner,
explain()would name a different source,and
loop_is_following()reads that name.Verified by breaking it (AC 6) —
tools/mutation_run.sh, purged treeClaim's dataclass default emptiedpeakreclassifiedSCOPE_UNITmaintenancereclassifiedSCOPE_SITEactuator_for()resolve()stops partitioningallocate()broadcasts at N≥2claims.appendsites routed to a wrong actuator, one at a timeresolve()ignores itsunitsargument (if units is None:→if True:)main.py's N≥2 refusal deleted (if not resolution.allocated:→if False:)⚠️ One of these started green and that is worth reporting. Emptying the
Claimdataclass default survived the first version of the check, becauseClaim.set()andClaim.limit()pass their own default down and the field'sdefault never runs. Anything building a
Claim(...)directly would have got theunspecified actuator this card exists to remove. The check now grades the
dataclass constructor as well.
The tripwire is kept and widened, not adapted around
test_peakguard.pypart 10f's static AST scan and dynamic SoC sweep areuntouched in substance.
sourceis stillClaim.set()'s first positionalargument precisely so the scan keeps reading it — which is why
actuatoristhe last field of
Claimand not the first. specs.md's point is that aclaim NAMES its actuator; the tuple position is not the part that carries
meaning, and moving
sourceoff position 0 would silently blind the tripwirethat guards SAFETY-25's deferred option.
One adaptation: the spy gained
**kw, somain.py's roster passes throughunread rather than being silently dropped (a fixed signature would raise on
every real cycle; a signature that dropped the roster would grade a different
arbitration from the one
main.pyperforms). What is observed — the claim list— is unchanged, and building Option B as a ninth source still takes it red.
New part 10g pins
CLAIM_SCOPEto the same eight-source set (a ninth sourcenow goes red twice), pins all eight call sites statically (below), and keeps
the real two-inverter cycle as the dynamic half — now labelled with the
three of eight appends it actually reaches.
⚠️ AC 6 was met at 3 of 8 call sites and the check claimed the class — fixed
This is the blocking finding from the review of
06f95e0a, and it wasright. 10g's only call-site check was the real two-inverter cycle, and its own
text read "the line a mis-typed
actuator_of(...)at a call site goes redon". It was not. That cycle is auto, P1-healthy, no maintenance procedure,
no tripped sign watchdog, the guard idle and arbitrage off — it reaches
device,supervisedandloopand nothing else, three of the eightdirect appends in
Controller.cycle(). The reviewer proved the gap on theshipped tree:
actuator_of("maintenance")→actuator_of("peak")at themaintenance-shaping append left
test_arbiter.py,test_peakguard.pyandtest_arbitrage.pyall green, while at a roster of two it built a site loopof −1500 W against the guard's site
loof 1500 W and the now-misroutedcharge-only
hi = 0→lo > hi, arbiter clause 3, 0 W in the billedquarter — SAFETY-25 §0a's fatal case, from a one-word typo. The same mutation
at the
failsafeappend survived too.scope_violations()does name it atruntime, but it only logs; nothing graded it.
That is this repository's named failure mode — a check observing something
other than what it claimed — reproduced inside the check written to prevent
it. Closed the way 10f closes it, over the same AST scan: all eight direct
appends expose their actuator as a literal
actuator=self.actuator_of('…'), sothe literal is now required to equal the source the claim is filed under,
read as source text rather than evaluated (
actuator_of("peak")at themaintenance append is the same value at the shipped roster of one and a
different string here). All eight, on the line the typo is written, in every
configuration and at every roster. Two further lines stop the scan going vacuous:
every direct append must be readable in that shape, and the ten appends must
still account for 10f's eight sources.
main.py)deviceloopmax_wlimitsupervisedpeakfailsafesafetysafetydevicesafetymaintenancemaintenanceloopmaintenancepeakloopsupervisedEight mutations, eight reds, each on its own purged tree via
tools/mutation_run.shover all 17 files, and every one of them red on thenew line rather than incidentally.
Which sites this does NOT reach, said at the line rather than rounded up.
The two indirect appends —
peak_claimandarb_claim— file a claim builtelsewhere in the method and carry no
actuator=at the call site tomis-type. They are pinned where their routing is actually chosen:
peakguard.claim()by theSWEEP_SITEsweep (every load × elapsed × battery ×SoC × floor combination),
arbitrage.claim()bytest_arbitrage.py. 10g saysso, and says which three of the eight its dynamic cycle sees.
Two more mutations that survived, same class, also closed
resolve()did not have to honour the roster it was given.if units is None:→if True:left the whole suite green. Ignoring the argument makes aunit that filed nothing this cycle cease to exist — a site of two reads as
a site of one, and stage 3 passes the whole grid target through to the
inverter that spoke: ARCH-01's fault with its own evidence removed.
test_arbiter.pypart 7 now grades it on the one case that separates the two,a silent unit. Mutation RED in arbiter and peakguard.
if not resolution.allocated:→if False:left the whole suite green, so the rightinstinct was one refactor away from being deleted as dead code. The guard is
kept — the alternative default is the ARCH-01 fault — and new part 10h
grades it on the two-inverter cycle that already existed: the 0 W, the
failsafewinner, the log line naming the roster size, and the write thatfollows it. Mutation RED.
SAFETY-24 (AC 5)
The combination sweep is re-run and holds: 0 negative floors,
histill+infon every claim the guard files. It now also asserts every one of themis on the SITE actuator — the other half of the same asymmetry now that a
claim has an actuator, because a floor bound to one inverter's share would leave
the site (the thing the capacity tariff meters) with no floor at all.
Things I am unsure about, or that a reviewer should push on
lo-onlyasymmetry "across 19,800 combinations". The sweep actually in the tree
(
test_peakguard.pypart 8) prints 1,296 — 6 loads × 3 elapsed × 4battery × 6 SoC × 3 floor. I re-ran the shipped proof and it holds, and I
report the number it prints rather than the memo's. I did not inflate the
sweep to reach 19,800: I could not find where that figure comes from, and
inventing dimensions to match a number in a memo is the wrong direction. If
the memo's figure refers to a sweep that was never committed, that is a
separate finding and probably a separate card. It is now
SAFETY-33.Theallocated=Falserefusal inmain.pyis arguably scope creep.RESOLVED. It was unreachable and ungraded —
if False:left the suitegreen. The reviewer's call was to keep it (the alternative default is the
ARCH-01 fault) and grade it, which part 10h now does. What it does is also
now stated accurately: it substitutes a 0 W failsafe and writes it, it does
not refuse to write.
UnitBoundscarriesgateandpinned_w, which is more than "aninterval". They are there because clause 2 gates a limit on the winning
set's priority, and at N ≥ 2 the winner is chosen at stage 2 — after stage 1.
Stage 1 states the constraint and records what it is gated on; deciding
whether it applies is stage 3's business. Guessing at stage 1 is how a
maintenance charge-only
hiwould come to bind a peak discharge it does notoutrank. If ALLOC-01 wants a plainer interval it can narrow this.
resolve(claims, units=None)derives the roster from the claims when it isnot given. That is right for a caller that files a claim for every unit
every cycle (
main.pydoes —supervisedis unconditional) and wrong for onethat does not.
main.pypasses the real roster; the default is documented asa convenience, not a contract. ⚠️ And the argument now has to be
honoured, which is not the same as existing — see the mutation above.
owner,setpoint_wandexpires_at.ownerissourceunder another name;expires_atis deliberately absentbecause claims in this implementation are re-filed every cycle and a
sticky claim is the failure
main.py's comments already warn about twice. Idid not want to smuggle a lifetime model in under a card about the actuator.
§0d says. 10g's two-inverter cycle is a routing check, not a two-pack SoC
fixture; whoever reaches the ALLOC-01 revisit still inherits that job.
actuator_of()'s argument, not itsanswer. It is deliberately textual — that is the only thing that separates
two routings which are the same value at N = 1 — so it grades what is
written, and the routing table itself is what 10g's other halves grade. An
append that computed its actuator would take the readability line red rather
than be skipped, which is the failure shape I was most worried about
re-introducing.
No config key, no option, no operator surface, no default moved. Inverter B
does not exist yet and nothing here measures it.
Full suite: 17/17 green in
goodwe-addon-ci. Mutations this round: 10run, 10 red — the eight call sites above, plus
if units is None:→if True:andif not resolution.allocated:→if False:— each on its own purgedtree via
tools/mutation_run.shover all 17 files.specs.md:467 declares `actuator: str` as the FIRST field of `Claim`. app/arbiter.py:55 had no such field. The specification designed per-actuator claims and the implementation dropped it, so every claim was implicitly about "the one and only actuator", resolve() returned one scalar Resolution.target_w, and main.py assigned it to one self.target. THERE WAS NO SEAM AN ALLOCATOR COULD OCCUPY - and ARCH-01 (specs.md §6.2, 2026-09-02) decided the site grid target is ALLOCATED across several inverters and never broadcast. This is a regression fix against the written design; creating that seam is the whole of the change. WHY IT MATTERS, IN THE UNITS THE SYSTEM IS BILLED IN. The control primitive is meter emulation: the master reports a fabricated grid reading and the inverter regulates itself to it, so one number means "the battery moves X W" to the add-on and "the grid is at X W" to the inverter - an identity that holds only at N=1. Broadcast one target to two inverters and each supplies it in full; the loop gain at the meter becomes N times the per-unit gain, which at the gain config.yaml:374 accepts is a permanent limit cycle with every watchdog in §11.2 reporting healthy. And a per-inverter reserve filed as a site `hi` against the peak guard's site `lo` is `lo > hi`, arbiter clause 3, 0 W IN THE BILLED QUARTER - the parked battery SAFETY-25's memo calls fatal. THE SPLIT, VERIFIED AT EACH CONSTRUCTION SITE RATHER THAN ACCEPTED FROM THE SURVEY. Four sources are about the GRID CONNECTION and are SITE claims: `loop` (compute() regulates grid_w, the P1 measurement, and §6.2's whole architecture is one integrator on P1 whose output is then split), `peak` (the 15-minute average offtake AT the grid connection, which is what the capacity tariff bills - one quarter-hour peak per site, not one per pack), `arbitrage` (buys and sells at the grid against a day-ahead price; the price is a property of the connection and the energy crosses the meter) and `safety` (both construction sites read: "stopped" is the operator switching control off for the site, "inputs missing" is the site's own meter gone - neither is about a pack, and both must stop every inverter). Four are about a pack, a device or one write and are PER-INVERTER: `device` (one inverter's own reported min/max - there is no site rating), `supervised` (max_w caps what may be WRITTEN to one setpoint entity, §11.3, so it is scoped like `device` for the same reason), `maintenance` (§12's recalibration is a pack procedure - drain THIS pack, charge THIS pack, recalibrate THIS pack's counter) and `failsafe`. `failsafe` is the entry that needed the reading rather than the survey: SAFETY-02's EVIDENCE is site-wide (it compares P1 against the standing command) but its CONCLUSION is "the unit I am driving is pushing power the wrong way" and its remedy is to park that unit. At N>=2 the same evidence cannot be attributed to a sibling this controller never commanded, so a site-wide stop would park an inverter this watchdog never graded. The reasoning for all eight is recorded AT THE LINE, in arbiter.CLAIM_SCOPE and again at each claims.append in main.py. WHAT WAS BUILT. resolve() is three stages, each a separate function so a test can construct a case at any one of them: pre_resolve() - one inverter's own claims -> that unit's interval. Clauses 1 and 2 transposed: the unit's own winning SET gates its own limits. The SET's value travels BESIDE the interval, not inside it - folding a -2500 W drain into [-2500, -2500] would turn "clamped to max_w" into "contradicts max_w", a different and wrong answer. site_resolve() - THE SHIPPED ARBITER, UNCHANGED. Same code, new name. allocate() - THE SEAM, AND DELIBERATELY NOT AN ALLOCATOR. ALLOC-01 builds it. At N<=1 it passes the target through (§6.2's identity). At N>=2 it DECLINES - no shares, allocated=False - and main.py refuses to write rather than broadcast, because the obvious placeholder (hand the target to each) is the fault ARCH-01 was filed about and no guard we have can tell it apart from the real thing. THE CONTRADICTION IS NOT GUARDED AGAINST, IT CANNOT BE WRITTEN DOWN. The partition is by `actuator` and it is a partition: a unit's `hi` and the site's `lo` are never both terms of one `lo > hi` test. Constructed and swept - 40 filings (every per-inverter source x unit x bound, including a unit contradicting ITSELF at lo 900 > hi 100) against a live site floor, and not one moves the site target or raises a contradiction. A unit's own impossible pair parks that unit and says so on its own bounds; it does not park the site. That is SAFETY-25 §0c's table made mechanical. AT N=1 THIS IS A PURE REFACTOR, AND THAT IS PROVEN RATHER THAN ASSERTED. With one inverter the fabricated grid reading and the battery setpoint are the same number, so the grid connection and the only unit ARE one actuator: actuator_for() routes every source to the site, no claim is filed on a unit, stage 2 sees the whole list and answers what the shipped arbiter answered. All 17 suites replay UNEDITED above the ARB-03 sections and are green on the same numbers; test_arbiter.py additionally runs 13 claim lists through both resolve() and site_resolve() and compares target, winner, binder, contradiction flag, the considered list and the operator line. EVIDENCE, BY BREAKING EACH THING (tools/mutation_run.sh, purged tree): * Claim's dataclass default emptied -> RED (test_arbiter) ...and it survived until the check was added: the two helpers pass their own default down, so the FIELD's default never ran. Now checked directly. * `peak` reclassified SCOPE_UNIT -> RED (arbiter x4, peakguard x2) * `maintenance` reclassified SCOPE_SITE -> RED (arbiter x4, peakguard) * the N=1 identity dropped in actuator_for() -> RED (arbiter, peakguard incl. the maintenance/peak suppression checks, freeze) - which is the measurement that says splitting at N=1 would be a behaviour change * resolve() stops partitioning -> RED (arbiter x4) * allocate() broadcasts at N>=2 -> RED (arbiter x2) * stage 1 drops clause-2 gating -> RED (arbiter) * a call site routes `device` through `loop` -> RED (peakguard 10g) * SAFETY-25 Option B built as a ninth source -> RED (peakguard 10f, both halves, plus 10g) - the tripwire still bites SAFETY-24's asymmetry proof is re-run and holds: 0 negative floors over the shipped sweep, `hi` still +inf on every claim the guard files, and it now also asserts every one of them is on the SITE actuator - a floor bound to one inverter's share would leave the site, the thing the capacity tariff meters, with no floor at all. test_peakguard.py part 10f is kept, not adapted around. `source` is still Claim.set()'s first positional argument precisely so its AST scan keeps reading it, which is why `actuator` is the LAST field of Claim and not the first - moving `source` off position 0 would silently blind the tripwire that guards SAFETY-25's deferred option. The one adaptation is `**kw` on its spy, so main.py's roster passes through unread rather than being silently dropped. New part 10g pins CLAIM_SCOPE to the same eight-source set and grades a REAL two-inverter cycle, which is the only place a mis-typed actuator_of(...) at a call site is observable - at N=1 every source legitimately routes to the site, so a swap there is invisible by construction. No config key, no option, no operator surface, no default moved. Inverter B does not exist yet and nothing here measures it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU