specs §1.5 makes "any configuration in which two writers can reach one actuator" a non-goal. That non-goal was enforced by counting config keys. app/validate.py:240:
ACTUATOR_WRITE_KEYS=("setpoint_entity",)
consumed at rule 1, with a comment stating the guarantee outright — "the way that non-goal is ENFORCED here is by construction: there is exactly one such key, so a second writer is not expressible in the options schema at all."
That was true, and it graded the shape of the options schema, not the writes. Add CFG-04's per-inverter config — a roster of inverters each with its own setpoint_entity — and there are two real writers with len(ACTUATOR_WRITE_KEYS) still equal to 1. The check stays green while the property it protects is gone. sim/inject.py's two_writers scenario asserted on the same tuple, so it went green too.
This is the repo's named failure mode: a check that observes something other than what it claims to observe.
The evidence, not the argument
test_validate.py part 2 now constructs the hazardous config, asserts the old proxy is still green on it, and then asserts the new check refuses it. The same construction run through sim/inject.py two_writers against release/1.0 @ 3030f72:
FAIL two_writers: validate_startup_config() ACCEPTED a configuration in which 3
declarations reach the ONE actuator number.goodwe_setpoint - the fixture's 2 writers
(METER_EMULATION, STORED_SCHEDULE) expressed as options. §1.5 makes that a non-goal.
Note that the old proxy reports 1 write key(s) on this same config and is therefore
GREEN on it: counting option keys does not count writers.
Against this branch it passes. (That sim change is a separate PR in admin/goodwe — see Ordering below.)
What this changes
1. The write path is gated on the record (AC 1)
Controller.write_refusal() asks Allocation.authorises() whether the single allocator placed exactly this number, for this unit, on this tick. write_setpoint() refuses anything else. A second writer with arithmetic of its own cannot satisfy that — its number appears in no record — so it has to file a Claim and go through resolve(), which is the one allocator. Exactly one allocator per shared resource stops being a convention somebody can forget.
The refusal is a substitution, not a return: 0 W goes on the wire in place of the number nothing stands behind. Returning would leave the inverter latched on its last command with the ESP32 heartbeat still being fed — holding an unauthorised setpoint quietly, which is the outcome the gate exists to stop. Part 7a grades the device's writes, not the log.
Placement: below the non-finite coercion (a nan is loss of input; rule 1 has already made it 0 W), above the clamp. Gating the clamped value would refuse exactly the cycles SAFETY-03 exists for, since a clamped write differs from the allocator's share by construction. Mutation M7 moves the gate below the clamp and turns nine checks red, part 6c among them.
2. A stale record cannot be replayed (AC 2)
Every Allocation now carries the tick it was made for. It is handed in by the caller (plan(..., tick=)) rather than counted inside the allocator, because test_alloc.py part 10 grades that the module holds no state at all — a serial incremented in there would be the cached-across-ticks thing the envelope rule forbids, wearing a different hat.
Controller.alloc_tick advances once per resolution, not once per loop iteration, and both halves are load-bearing:
a new allocation makes the previous record stale immediately — graded by capturing a record from a real cycle, running another real cycle, putting the old one back and observing refusal;
the "inputs missing, not yet stale" hold returns before the allocator runs, precisely so one dropped meter reading does not zero the inverter, and the heartbeat re-affirms self.target through this same gate while it does. A tick incremented per loop iteration would refuse that heartbeat and turn the hold into the outage it exists to prevent. Also graded.
An unstamped record (tick is None) authorises nothing: "I cannot say when I was made" fails closed rather than reading as tick zero.
3. ⚠️ The failsafe cannot deadlock behind the gate — and here is which way I resolved it
write_setpoint() is also how layer 2 commands 0 W. cycle()'s two refusal branches substitute a 0 W failsafe and write it like any other cycle; shutdown() and the operator's stop button do the same. Every one of those runs while the allocator has refused — ok is False and there are no shares. A gate that asked the allocator's permission to command zero would block the safety write behind the very component whose failure the safety write answers.
0 W is authorised unconditionally, and on the value, not on a failsafe=True flag. A flag is an assertion by the caller, which is exactly what a second writer would pass; the value is a fact about what is going to be written.
Why that is safe and not merely convenient: the hazard §1.5 names is N-times loop gain from broadcast — handing every inverter the same target so each supplies it in full (allocator.py's opening argument; TEST-19 measured a permanent 4000 W p2p limit cycle at gain: 1.0 with not one guard firing). At S = 0 the broadcast and the correct allocation coincide: a_i = 1 for every unit gives every unit 0 W, which is exactly its share. There is no over-command to make at zero. Said at the line in write_refusal().
test_clamp.py part 7c drives it end to end through the realcycle() and the real REFUSE_EMPTY branch: the allocator refuses every tick, the device still sees 0 W, and the action log says ARBITER: REFUSED rather than WRITE REFUSED so the two failures stay distinguishable to whoever debugs them. Part 7c also checks the three hostile states (no record, refused record, stale record) authorise 0 W and still refuse a non-zero value, so the zero exit is a floor and not an amnesty (mutation M2).
4. unmet_w is still not ok
The gate reads ok and the unit's share and neverunmet_w — an allocation that could not place every watt is still safe to write, and fusing them would let one saturated inverter park the whole site. Graded on a record with 3800 W unmet and every share legal; mutation M5 adds or self.unmet_w and that check goes red.
5. The config check, replaced rather than deleted (AC 3)
actuator_writers() counts writers per actuator instead of option keys. It walks the options tree rather than looking for a container called inverters, because CFG-04 has not chosen that name yet and a census that guessed wrong would go green while two writers existed — the same defect one step along. Entity ids are folded to lower case.
two inverters with two entities are accepted — that is what the per-inverter roster is for, and a validator that refuses the feature it guards is one somebody switches off;
two declarations aimed at one entity are refused, naming the actuator and every writer pointed at it — including the case an author meets first (keep setpoint_entity as A's, list A again), because "they happen to be the same entity, so it is fine" is precisely the reasoning that makes two writers invisible.
ACTUATOR_WRITE_KEYS survives only as the enumeration a census needs, with its guarantee comment deleted. Nothing asserts on its length any more.
6. What neither check observes, said out loud
The census sees configured writers. A second write path added in code — a hass.set_number() call next to some new strategy — declares nothing and is invisible to it, exactly as it was invisible to the tuple, and so was the §6 incident §17.5 cites. That half is not closed at startup and cannot be; it is closed at runtime by the record gate, which does not care what the options look like or who called the method. The two checks are deliberately different kinds, and only the runtime one survives a schema validate.py has not seen yet.
Verified by breaking it (AC 6)
Seven mutations, each on a purged tree via tools/mutation_run.sh, each reverted and the baseline re-verified (the mutation tree is byte-identical to this branch apart from CHANGELOG.md):
#
mutation
result
M1
remove the gate from write_setpoint()
test_clamp red — 4 checks
M2
make the zero exit an amnesty (if True: return None)
test_clamp red — 12 checks
M3
drop the tick comparison in authorises()
test_clamp red — 5 checks
M4
delete the per-actuator census refusal
test_validate red — 3 checks
M5
fuse unmet_w into ok
test_clamp red — 1 check
M6
freeze alloc_tick on the live path
test_clamp red — 2 checks
M7
move the gate below the clamp
test_clamp red — 9 checks
M6 is the one worth naming: everything in part 7 up to that point drives the test's own arm() helper, which bumps the counter itself, so all of it would stay green with self.alloc_tick += 1 deleted from cycle() — and the replay protection would be gone on the only path that matters. The check that catches it drives real cycles.
AC 5 — at N=1 nothing changes
Full 18-file suite green on a purged tree. No existing assertion was weakened. The two edits to test_clamp.py are fixtures, not assertions: parts 5 and 6c call write_setpoint() directly with a number nothing allocated, which is now refused, so they say where their number came from (through the shipped resolve(), not by hand — a helper that assembled an Allocation itself would be a second allocator living in the test file). Every assertion in both parts is unchanged. Part 7f then asserts that across part 3's sixty real cycles the gate refused zero writes — a gate silently substituting 0 W there would leave every clamp check green and the install dead.
admin/goodwe's TEST-19 norig scenarios (planner_binding, two_master_plant, two_master_broadcast) also re-run green against this branch, since they import the add-on's control law across the repo boundary.
Ordering with the sim PR
checks.yml in admin/goodwe checks the add-on out at release/1.0, not at a PR ref. So:
this PR first. Merging it does not break admin/goodwe's current two_writers — verified by running release/1.0's inject.py against this branch (green). Keeping the name ACTUATOR_WRITE_KEYS, demoted to an enumeration, is what buys that.
the admin/goodwe PR second. Until this one merges, that scenario is red there, truthfully reporting that the add-on does not yet refuse two writers on one actuator.
Things I am not sure about
The duplicate-declaration rule may be stricter than CFG-04 wants. I refuse two declarations naming one entity even when both are the same entity — e.g. keeping setpoint_entity as inverter A's and listing A in the roster. That is a shape CFG-04's author might reasonably have intended as "the scalar is A". I chose to refuse and say so in the message ("declare each actuator exactly once"), because deduplicating would mean deciding that two config sites with their own neighbouring options are one writer, which is the assumption that makes the defect invisible. If CFG-04 wants the overlap, this is the line to revisit — deliberately, not by accident.
The census cannot see a writer declared under a key name it does not know. If CFG-04 calls its entity write_entity instead of setpoint_entity, ACTUATOR_WRITE_KEYS needs the entry and nothing here will remind anyone. The runtime gate covers the consequence, but the startup message would not fire. I did not try to guess future key names.
tick threading touches three signatures (plan, allocate_record, resolve), all keyword-only with defaults, so no existing call changed. It is more plumbing than a module-level counter would have been; I chose it because test_alloc.py part 10 asserts the allocator holds no state, and gaming that check to save three lines is the exact move this ticket exists to punish.
self.last_write overlaps with DEV-02, as the PM flagged. I did not touch it — my change is above it in write_setpoint() — but whoever merges second merges release/1.0 and re-runs.
## The defect
specs §1.5 makes *"any configuration in which two writers can reach one actuator"* a non-goal. That non-goal was enforced **by counting config keys**. `app/validate.py:240`:
```python
ACTUATOR_WRITE_KEYS = ("setpoint_entity",)
```
consumed at rule 1, with a comment stating the guarantee outright — *"the way that non-goal is ENFORCED here is by construction: there is exactly one such key, so a second writer is not expressible in the options schema at all."*
That was true, and it graded **the shape of the options schema, not the writes**. Add CFG-04's per-inverter config — a roster of inverters each with its own `setpoint_entity` — and there are two real writers with `len(ACTUATOR_WRITE_KEYS)` still equal to 1. The check stays green while the property it protects is gone. `sim/inject.py`'s `two_writers` scenario asserted on the same tuple, so it went green too.
This is the repo's named failure mode: a check that observes something other than what it claims to observe.
## The evidence, not the argument
`test_validate.py` part 2 now **constructs** the hazardous config, asserts the **old** proxy is still green on it, and then asserts the new check refuses it. The same construction run through `sim/inject.py two_writers` against **release/1.0 @ 3030f72**:
```
FAIL two_writers: validate_startup_config() ACCEPTED a configuration in which 3
declarations reach the ONE actuator number.goodwe_setpoint - the fixture's 2 writers
(METER_EMULATION, STORED_SCHEDULE) expressed as options. §1.5 makes that a non-goal.
Note that the old proxy reports 1 write key(s) on this same config and is therefore
GREEN on it: counting option keys does not count writers.
```
Against this branch it passes. (That sim change is a separate PR in `admin/goodwe` — see **Ordering** below.)
## What this changes
### 1. The write path is gated on the record (AC 1)
`Controller.write_refusal()` asks `Allocation.authorises()` whether the single allocator placed **exactly this number, for this unit, on this tick**. `write_setpoint()` refuses anything else. A second writer with arithmetic of its own cannot satisfy that — its number appears in no record — so it has to file a `Claim` and go through `resolve()`, which is the one allocator. *Exactly one allocator per shared resource* stops being a convention somebody can forget.
The refusal is a **substitution, not a return**: 0 W goes on the wire in place of the number nothing stands behind. Returning would leave the inverter latched on its last command with the ESP32 heartbeat still being fed — holding an unauthorised setpoint quietly, which is the outcome the gate exists to stop. Part 7a grades the **device's writes**, not the log.
**Placement**: below the non-finite coercion (a nan is loss of input; rule 1 has already made it 0 W), **above the clamp**. Gating the *clamped* value would refuse exactly the cycles SAFETY-03 exists for, since a clamped write differs from the allocator's share by construction. Mutation M7 moves the gate below the clamp and turns nine checks red, part 6c among them.
### 2. A stale record cannot be replayed (AC 2)
Every `Allocation` now carries the `tick` it was made for. It is handed **in** by the caller (`plan(..., tick=)`) rather than counted inside the allocator, because `test_alloc.py` part 10 grades that the module holds no state at all — a serial incremented in there would be the cached-across-ticks thing the envelope rule forbids, wearing a different hat.
`Controller.alloc_tick` advances **once per resolution, not once per loop iteration**, and both halves are load-bearing:
* a new allocation makes the previous record stale **immediately** — graded by capturing a record from a real cycle, running another real cycle, putting the old one back and observing refusal;
* the *"inputs missing, not yet stale"* hold returns **before** the allocator runs, precisely so one dropped meter reading does not zero the inverter, and the heartbeat re-affirms `self.target` through this same gate while it does. A tick incremented per loop iteration would refuse that heartbeat and **turn the hold into the outage it exists to prevent**. Also graded.
An **unstamped** record (`tick is None`) authorises nothing: *"I cannot say when I was made"* fails closed rather than reading as tick zero.
### 3. ⚠️ The failsafe cannot deadlock behind the gate — and here is which way I resolved it
`write_setpoint()` is also how layer 2 commands 0 W. `cycle()`'s two refusal branches substitute a 0 W failsafe and write it like any other cycle; `shutdown()` and the operator's stop button do the same. **Every one of those runs while the allocator has refused** — `ok` is False and there are no shares. A gate that asked the allocator's permission to command zero would block the safety write behind the very component whose failure the safety write answers.
**0 W is authorised unconditionally, and on the *value*, not on a `failsafe=True` flag.** A flag is an assertion by the caller, which is exactly what a second writer would pass; the value is a fact about what is going to be written.
Why that is safe and not merely convenient: the hazard §1.5 names is N-times loop gain from **broadcast** — handing every inverter the same target so each supplies it in full (`allocator.py`'s opening argument; TEST-19 measured a permanent 4000 W p2p limit cycle at `gain: 1.0` with not one guard firing). **At S = 0 the broadcast and the correct allocation coincide**: `a_i = 1` for every unit gives every unit 0 W, which is exactly its share. There is no over-command to make at zero. Said at the line in `write_refusal()`.
`test_clamp.py` part 7c drives it end to end through the **real** `cycle()` and the real `REFUSE_EMPTY` branch: the allocator refuses every tick, the device still sees 0 W, and the action log says `ARBITER: REFUSED` rather than `WRITE REFUSED` so the two failures stay distinguishable to whoever debugs them. Part 7c also checks the three hostile states (no record, refused record, stale record) authorise 0 W **and still refuse a non-zero value**, so the zero exit is a floor and not an amnesty (mutation M2).
### 4. `unmet_w` is still not `ok`
The gate reads `ok` and the unit's share and **never** `unmet_w` — an allocation that could not place every watt is still safe to write, and fusing them would let one saturated inverter park the whole site. Graded on a record with 3800 W unmet and every share legal; mutation M5 adds `or self.unmet_w` and that check goes red.
### 5. The config check, replaced rather than deleted (AC 3)
`actuator_writers()` counts **writers per actuator** instead of option keys. It walks the options **tree** rather than looking for a container called `inverters`, because CFG-04 has not chosen that name yet and a census that guessed wrong would go green while two writers existed — the same defect one step along. Entity ids are folded to lower case.
* two inverters with **two entities** are **accepted** — that is what the per-inverter roster is *for*, and a validator that refuses the feature it guards is one somebody switches off;
* two declarations aimed at **one entity** are refused, naming the actuator and every writer pointed at it — including the case an author meets first (keep `setpoint_entity` as A's, list A again), because *"they happen to be the same entity, so it is fine"* is precisely the reasoning that makes two writers invisible.
`ACTUATOR_WRITE_KEYS` survives **only** as the enumeration a census needs, with its guarantee comment deleted. Nothing asserts on its length any more.
### 6. What neither check observes, said out loud
The census sees **configured** writers. A second write path added in **code** — a `hass.set_number()` call next to some new strategy — declares nothing and is invisible to it, exactly as it was invisible to the tuple, and so was the §6 incident §17.5 cites. That half is not closed at startup and cannot be; it is closed at runtime by the record gate, which does not care what the options look like or who called the method. The two checks are deliberately different kinds, and only the runtime one survives a schema `validate.py` has not seen yet.
## Verified by breaking it (AC 6)
Seven mutations, each on a **purged** tree via `tools/mutation_run.sh`, each reverted and the baseline re-verified (the mutation tree is byte-identical to this branch apart from `CHANGELOG.md`):
| # | mutation | result |
|---|---|---|
| M1 | remove the gate from `write_setpoint()` | `test_clamp` red — 4 checks |
| M2 | make the zero exit an amnesty (`if True: return None`) | `test_clamp` red — 12 checks |
| M3 | drop the tick comparison in `authorises()` | `test_clamp` red — 5 checks |
| M4 | delete the per-actuator census refusal | `test_validate` red — 3 checks |
| M5 | fuse `unmet_w` into `ok` | `test_clamp` red — 1 check |
| M6 | freeze `alloc_tick` on the live path | `test_clamp` red — 2 checks |
| M7 | move the gate below the clamp | `test_clamp` red — 9 checks |
M6 is the one worth naming: everything in part 7 up to that point drives the test's own `arm()` helper, which bumps the counter itself, so all of it would stay green with `self.alloc_tick += 1` deleted from `cycle()` — and the replay protection would be gone on the only path that matters. The check that catches it drives real cycles.
## AC 5 — at N=1 nothing changes
Full 18-file suite green on a purged tree. **No existing assertion was weakened.** The two edits to `test_clamp.py` are *fixtures*, not assertions: parts 5 and 6c call `write_setpoint()` directly with a number nothing allocated, which is now refused, so they say where their number came from (through the shipped `resolve()`, not by hand — a helper that assembled an `Allocation` itself would be a second allocator living in the test file). Every assertion in both parts is unchanged. Part 7f then asserts that across part 3's **sixty real cycles** the gate refused **zero** writes — a gate silently substituting 0 W there would leave every clamp check green and the install dead.
`admin/goodwe`'s TEST-19 norig scenarios (`planner_binding`, `two_master_plant`, `two_master_broadcast`) also re-run green against this branch, since they import the add-on's control law across the repo boundary.
## Ordering with the sim PR
`checks.yml` in `admin/goodwe` checks the add-on out at **`release/1.0`**, not at a PR ref. So:
* **this PR first.** Merging it does **not** break `admin/goodwe`'s current `two_writers` — verified by running release/1.0's `inject.py` against this branch (green). Keeping the name `ACTUATOR_WRITE_KEYS`, demoted to an enumeration, is what buys that.
* the `admin/goodwe` PR second. Until this one merges, that scenario is red **there**, truthfully reporting that the add-on does not yet refuse two writers on one actuator.
## Things I am not sure about
1. **The duplicate-declaration rule may be stricter than CFG-04 wants.** I refuse two declarations naming one entity even when both are the same entity — e.g. keeping `setpoint_entity` as inverter A's *and* listing A in the roster. That is a shape CFG-04's author might reasonably have intended as "the scalar is A". I chose to refuse and say so in the message ("declare each actuator exactly once"), because deduplicating would mean deciding that two config sites with their own neighbouring options are one writer, which is the assumption that makes the defect invisible. If CFG-04 wants the overlap, this is the line to revisit — deliberately, not by accident.
2. **The census cannot see a writer declared under a key name it does not know.** If CFG-04 calls its entity `write_entity` instead of `setpoint_entity`, `ACTUATOR_WRITE_KEYS` needs the entry and nothing here will remind anyone. The runtime gate covers the consequence, but the startup message would not fire. I did not try to guess future key names.
3. **`tick` threading touches three signatures** (`plan`, `allocate_record`, `resolve`), all keyword-only with defaults, so no existing call changed. It is more plumbing than a module-level counter would have been; I chose it because `test_alloc.py` part 10 asserts the allocator holds no state, and gaming that check to save three lines is the exact move this ticket exists to punish.
4. **`self.last_write` overlaps with DEV-02**, as the PM flagged. I did not touch it — my change is above it in `write_setpoint()` — but whoever merges second merges `release/1.0` and re-runs.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
specs §1.5 makes "any configuration in which two writers can reach one
actuator" a non-goal. That non-goal was enforced by counting config keys:
validate.py's ACTUATOR_WRITE_KEYS = ("setpoint_entity",), consumed at rule 1,
with a comment stating the guarantee outright - "there is exactly one such
key, so a second writer is not expressible in the options schema at all".
That was true and it graded THE SHAPE OF THE OPTIONS SCHEMA, not the writes.
Add CFG-04's per-inverter config - a roster of inverters each with its own
setpoint_entity - and there are two real writers with len() of that tuple
still equal to 1. The check stays green while the property it protects is
gone. sim/inject.py's two_writers scenario asserted on the same tuple, so it
went green too. That is this repository's named failure mode: a check that
observes something other than what it claims to observe.
Demonstrated rather than argued. test_validate.py part 2 now constructs the
hazardous per-inverter config, asserts that the OLD proxy is still green on
it, and then asserts the new check refuses it. Against release/1.0's
validator the updated sim scenario reports: "validate_startup_config()
ACCEPTED a configuration in which 3 declarations reach the ONE actuator
number.goodwe_setpoint ... the old proxy reports 1 write key(s) on this same
config and is therefore GREEN on it".
THE WRITE PATH IS NOW GATED ON THE RECORD
=========================================
Controller.write_refusal() asks Allocation.authorises() whether the single
allocator placed exactly this number, for this unit, on this tick.
write_setpoint() refuses anything else. A second writer with arithmetic of
its own cannot satisfy that - its number appears in no record - so it has to
file a Claim and go through resolve(), which is the one allocator. "Exactly
one allocator per shared resource" stops being a convention somebody can
forget and becomes something the write path checks.
The refusal is a SUBSTITUTION, not a return: 0 W goes on the wire in place of
the number nothing stands behind. Returning would leave the inverter latched
on its last command with the ESP32 heartbeat still being fed - holding an
unauthorised setpoint quietly, which is the outcome the gate exists to stop.
AC 1 is "refused at the write path, not merely logged", and test_clamp.py
part 7a grades the device's writes, not the log.
The gate sits BELOW the non-finite coercion and ABOVE the clamp. Below,
because a nan is loss of input and rule 1 has already turned it into 0 W.
Above, because the question is who chose this number, and the answer is only
meaningful about the number that was asked for - gating the CLAMPED value
would refuse exactly the cycles SAFETY-03 exists for, since a clamped write
differs from the allocator's share by construction. Mutation M7 moves the
gate below the clamp and turns nine checks red, part 6c among them.
A STALE RECORD CANNOT BE REPLAYED
=================================
Every Allocation now carries the `tick` it was made for. It is handed IN by
the caller - plan(..., tick=) - rather than counted inside the allocator,
because test_alloc.py part 10 grades that the module holds no state at all,
and a serial number incremented in there would be the cached-across-ticks
thing the envelope rule forbids wearing a different hat.
Controller.alloc_tick advances ONCE PER RESOLUTION, not once per loop
iteration, and both halves are load-bearing:
* a new allocation makes the previous record stale immediately, so a record
whose envelopes have moved cannot authorise a write. That is the replay
half (AC 2), graded by capturing a real record, running another real
cycle, putting the old one back and observing refusal.
* the "inputs missing, not yet stale" hold returns BEFORE the allocator
runs, exactly so one dropped meter reading does not zero the inverter,
and the heartbeat re-affirms self.target through this same gate while it
does. A tick incremented per loop iteration would refuse that heartbeat
and turn the hold into the outage it exists to prevent. Graded.
An UNSTAMPED record (tick None) authorises nothing: "I cannot say when I was
made" fails closed rather than reading as tick zero.
THE FAILSAFE CANNOT DEADLOCK BEHIND THE GATE, AND HERE IS WHY
=============================================================
write_setpoint() is also how layer 2 commands 0 W. cycle()'s two refusal
branches substitute a 0 W failsafe and write it like any other cycle;
shutdown() and the operator's stop button do the same. EVERY ONE OF THOSE
RUNS WHILE THE ALLOCATOR HAS REFUSED - ok is False and there are no shares -
so a gate that asked the allocator's permission to command zero would block
the safety write behind the very component whose failure the safety write
answers. A gate that can do that is worse than the hole it closes.
Resolved by authorising 0 W unconditionally, and on the VALUE rather than on
a failsafe=True flag: a flag is an assertion by the caller, which is exactly
what a second writer would pass; the value is a fact about what will be
written. The argument that this is safe and not merely convenient is that the
hazard §1.5 names is N-times loop gain from BROADCAST - handing every inverter
the same target so each supplies it in full (allocator.py's opening argument;
TEST-19 measured a permanent 4000 W p2p limit cycle at gain 1.0 with no guard
firing). At S = 0 the broadcast and the correct allocation COINCIDE: a_i = 1
for every unit gives every unit 0 W, which is exactly its share. There is no
over-command to make at zero. Said at the line, in write_refusal().
test_clamp.py part 7c drives it end to end through the real cycle() and the
real REFUSE_EMPTY branch: the allocator refuses every tick, the device still
sees 0 W, and the action log says ARBITER: REFUSED rather than WRITE REFUSED
so the two failures stay distinguishable to whoever has to debug them.
unmet_w IS STILL NOT ok
=======================
The gate reads ok and the unit's share and never unmet_w. allocator.py:179
says why: an allocation that could not place every watt is still safe to
write, and fusing them would let one saturated inverter park the whole site.
Graded on a record with 3800 W unmet and every share legal - mutation M5 adds
`or self.unmet_w` to the gate and that check goes red.
THE CONFIG CHECK, REPLACED RATHER THAN DELETED
==============================================
actuator_writers() counts WRITERS PER ACTUATOR instead of option keys. It
walks the options TREE rather than looking for a container called `inverters`,
because CFG-04 has not chosen that name yet and a census that guessed wrong
would go green while two writers existed - the same defect one step along.
Entity ids are folded to lower case so a second writer cannot be smuggled in
as Number.X versus number.x.
Two inverters with two entities are ACCEPTED - that is what the per-inverter
roster is for, and a validator that refuses the feature it guards is one
somebody switches off. Two declarations aimed at ONE entity are refused,
naming the actuator and every writer pointed at it. That includes the case an
author will meet first - keeping setpoint_entity as A's and listing A again -
because "they happen to be the same entity, so it is fine" is precisely the
reasoning that makes two writers invisible.
ACTUATOR_WRITE_KEYS survives ONLY as the enumeration a census needs, with its
guarantee comment deleted; nothing asserts on its length any more. Keeping the
name is also what stops admin/goodwe's current two_writers scenario failing on
an AttributeError between the two merges.
WHAT NEITHER CHECK OBSERVES, SAID OUT LOUD
==========================================
The census sees CONFIGURED writers. A second write path added in CODE - a
hass.set_number() call next to some new strategy - declares nothing and is
invisible to it, exactly as it was invisible to the tuple. That half is not
closed at startup and cannot be; it is closed at runtime by the record gate,
which does not care what the options look like or who called the method. The
two checks are deliberately different kinds, and only the runtime one survives
a schema this file has not seen yet.
EVIDENCE
========
* Full 18-file suite green on a purged tree (tools/mutation_run.sh).
* Verified by breaking it - seven mutations, each on a purged tree, each
reverted and re-verified:
M1 remove the gate -> test_clamp red (4 checks)
M2 make the zero exit an amnesty -> test_clamp red (12 checks)
M3 drop the tick comparison -> test_clamp red (5 checks)
M4 delete the census refusal -> test_validate red (3 checks)
M5 fuse unmet_w into ok -> test_clamp red (1 check)
M6 freeze the tick on the live path -> test_clamp red (2 checks)
M7 move the gate below the clamp -> test_clamp red (9 checks)
* AC 5: no existing check was weakened. The two edits to test_clamp.py are
fixtures, not assertions - parts 5 and 6c call write_setpoint() directly
with a number nothing allocated, which is now refused, so they say where
their number came from. Every assertion in both parts is unchanged, and
part 7f asserts that across part 3's sixty real cycles the gate refused
zero writes.
* admin/goodwe's TEST-19 norig scenarios (planner_binding, two_master_plant,
two_master_broadcast) still pass against this tree - they import the
add-on's control law across the repo boundary.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
admin
merged commit f594543988 into release/1.02026-09-02 15:44:41 +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 §1.5 makes "any configuration in which two writers can reach one actuator" a non-goal. That non-goal was enforced by counting config keys.
app/validate.py:240:consumed at rule 1, with a comment stating the guarantee outright — "the way that non-goal is ENFORCED here is by construction: there is exactly one such key, so a second writer is not expressible in the options schema at all."
That was true, and it graded the shape of the options schema, not the writes. Add CFG-04's per-inverter config — a roster of inverters each with its own
setpoint_entity— and there are two real writers withlen(ACTUATOR_WRITE_KEYS)still equal to 1. The check stays green while the property it protects is gone.sim/inject.py'stwo_writersscenario asserted on the same tuple, so it went green too.This is the repo's named failure mode: a check that observes something other than what it claims to observe.
The evidence, not the argument
test_validate.pypart 2 now constructs the hazardous config, asserts the old proxy is still green on it, and then asserts the new check refuses it. The same construction run throughsim/inject.py two_writersagainst release/1.0 @3030f72:Against this branch it passes. (That sim change is a separate PR in
admin/goodwe— see Ordering below.)What this changes
1. The write path is gated on the record (AC 1)
Controller.write_refusal()asksAllocation.authorises()whether the single allocator placed exactly this number, for this unit, on this tick.write_setpoint()refuses anything else. A second writer with arithmetic of its own cannot satisfy that — its number appears in no record — so it has to file aClaimand go throughresolve(), which is the one allocator. Exactly one allocator per shared resource stops being a convention somebody can forget.The refusal is a substitution, not a return: 0 W goes on the wire in place of the number nothing stands behind. Returning would leave the inverter latched on its last command with the ESP32 heartbeat still being fed — holding an unauthorised setpoint quietly, which is the outcome the gate exists to stop. Part 7a grades the device's writes, not the log.
Placement: below the non-finite coercion (a nan is loss of input; rule 1 has already made it 0 W), above the clamp. Gating the clamped value would refuse exactly the cycles SAFETY-03 exists for, since a clamped write differs from the allocator's share by construction. Mutation M7 moves the gate below the clamp and turns nine checks red, part 6c among them.
2. A stale record cannot be replayed (AC 2)
Every
Allocationnow carries thetickit was made for. It is handed in by the caller (plan(..., tick=)) rather than counted inside the allocator, becausetest_alloc.pypart 10 grades that the module holds no state at all — a serial incremented in there would be the cached-across-ticks thing the envelope rule forbids, wearing a different hat.Controller.alloc_tickadvances once per resolution, not once per loop iteration, and both halves are load-bearing:self.targetthrough this same gate while it does. A tick incremented per loop iteration would refuse that heartbeat and turn the hold into the outage it exists to prevent. Also graded.An unstamped record (
tick is None) authorises nothing: "I cannot say when I was made" fails closed rather than reading as tick zero.3. ⚠️ The failsafe cannot deadlock behind the gate — and here is which way I resolved it
write_setpoint()is also how layer 2 commands 0 W.cycle()'s two refusal branches substitute a 0 W failsafe and write it like any other cycle;shutdown()and the operator's stop button do the same. Every one of those runs while the allocator has refused —okis False and there are no shares. A gate that asked the allocator's permission to command zero would block the safety write behind the very component whose failure the safety write answers.0 W is authorised unconditionally, and on the value, not on a
failsafe=Trueflag. A flag is an assertion by the caller, which is exactly what a second writer would pass; the value is a fact about what is going to be written.Why that is safe and not merely convenient: the hazard §1.5 names is N-times loop gain from broadcast — handing every inverter the same target so each supplies it in full (
allocator.py's opening argument; TEST-19 measured a permanent 4000 W p2p limit cycle atgain: 1.0with not one guard firing). At S = 0 the broadcast and the correct allocation coincide:a_i = 1for every unit gives every unit 0 W, which is exactly its share. There is no over-command to make at zero. Said at the line inwrite_refusal().test_clamp.pypart 7c drives it end to end through the realcycle()and the realREFUSE_EMPTYbranch: the allocator refuses every tick, the device still sees 0 W, and the action log saysARBITER: REFUSEDrather thanWRITE REFUSEDso the two failures stay distinguishable to whoever debugs them. Part 7c also checks the three hostile states (no record, refused record, stale record) authorise 0 W and still refuse a non-zero value, so the zero exit is a floor and not an amnesty (mutation M2).4.
unmet_wis still notokThe gate reads
okand the unit's share and neverunmet_w— an allocation that could not place every watt is still safe to write, and fusing them would let one saturated inverter park the whole site. Graded on a record with 3800 W unmet and every share legal; mutation M5 addsor self.unmet_wand that check goes red.5. The config check, replaced rather than deleted (AC 3)
actuator_writers()counts writers per actuator instead of option keys. It walks the options tree rather than looking for a container calledinverters, because CFG-04 has not chosen that name yet and a census that guessed wrong would go green while two writers existed — the same defect one step along. Entity ids are folded to lower case.setpoint_entityas A's, list A again), because "they happen to be the same entity, so it is fine" is precisely the reasoning that makes two writers invisible.ACTUATOR_WRITE_KEYSsurvives only as the enumeration a census needs, with its guarantee comment deleted. Nothing asserts on its length any more.6. What neither check observes, said out loud
The census sees configured writers. A second write path added in code — a
hass.set_number()call next to some new strategy — declares nothing and is invisible to it, exactly as it was invisible to the tuple, and so was the §6 incident §17.5 cites. That half is not closed at startup and cannot be; it is closed at runtime by the record gate, which does not care what the options look like or who called the method. The two checks are deliberately different kinds, and only the runtime one survives a schemavalidate.pyhas not seen yet.Verified by breaking it (AC 6)
Seven mutations, each on a purged tree via
tools/mutation_run.sh, each reverted and the baseline re-verified (the mutation tree is byte-identical to this branch apart fromCHANGELOG.md):write_setpoint()test_clampred — 4 checksif True: return None)test_clampred — 12 checksauthorises()test_clampred — 5 checkstest_validatered — 3 checksunmet_wintooktest_clampred — 1 checkalloc_tickon the live pathtest_clampred — 2 checkstest_clampred — 9 checksM6 is the one worth naming: everything in part 7 up to that point drives the test's own
arm()helper, which bumps the counter itself, so all of it would stay green withself.alloc_tick += 1deleted fromcycle()— and the replay protection would be gone on the only path that matters. The check that catches it drives real cycles.AC 5 — at N=1 nothing changes
Full 18-file suite green on a purged tree. No existing assertion was weakened. The two edits to
test_clamp.pyare fixtures, not assertions: parts 5 and 6c callwrite_setpoint()directly with a number nothing allocated, which is now refused, so they say where their number came from (through the shippedresolve(), not by hand — a helper that assembled anAllocationitself would be a second allocator living in the test file). Every assertion in both parts is unchanged. Part 7f then asserts that across part 3's sixty real cycles the gate refused zero writes — a gate silently substituting 0 W there would leave every clamp check green and the install dead.admin/goodwe's TEST-19 norig scenarios (planner_binding,two_master_plant,two_master_broadcast) also re-run green against this branch, since they import the add-on's control law across the repo boundary.Ordering with the sim PR
checks.ymlinadmin/goodwechecks the add-on out atrelease/1.0, not at a PR ref. So:admin/goodwe's currenttwo_writers— verified by running release/1.0'sinject.pyagainst this branch (green). Keeping the nameACTUATOR_WRITE_KEYS, demoted to an enumeration, is what buys that.admin/goodwePR second. Until this one merges, that scenario is red there, truthfully reporting that the add-on does not yet refuse two writers on one actuator.Things I am not sure about
setpoint_entityas inverter A's and listing A in the roster. That is a shape CFG-04's author might reasonably have intended as "the scalar is A". I chose to refuse and say so in the message ("declare each actuator exactly once"), because deduplicating would mean deciding that two config sites with their own neighbouring options are one writer, which is the assumption that makes the defect invisible. If CFG-04 wants the overlap, this is the line to revisit — deliberately, not by accident.write_entityinstead ofsetpoint_entity,ACTUATOR_WRITE_KEYSneeds the entry and nothing here will remind anyone. The runtime gate covers the consequence, but the startup message would not fire. I did not try to guess future key names.tickthreading touches three signatures (plan,allocate_record,resolve), all keyword-only with defaults, so no existing call changed. It is more plumbing than a module-level counter would have been; I chose it becausetest_alloc.pypart 10 asserts the allocator holds no state, and gaming that check to save three lines is the exact move this ticket exists to punish.self.last_writeoverlaps with DEV-02, as the PM flagged. I did not touch it — my change is above it inwrite_setpoint()— but whoever merges second mergesrelease/1.0and re-runs.🤖 Generated with Claude Code
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
specs §1.5 makes "any configuration in which two writers can reach one actuator" a non-goal. That non-goal was enforced by counting config keys: validate.py's ACTUATOR_WRITE_KEYS = ("setpoint_entity",), consumed at rule 1, with a comment stating the guarantee outright - "there is exactly one such key, so a second writer is not expressible in the options schema at all". That was true and it graded THE SHAPE OF THE OPTIONS SCHEMA, not the writes. Add CFG-04's per-inverter config - a roster of inverters each with its own setpoint_entity - and there are two real writers with len() of that tuple still equal to 1. The check stays green while the property it protects is gone. sim/inject.py's two_writers scenario asserted on the same tuple, so it went green too. That is this repository's named failure mode: a check that observes something other than what it claims to observe. Demonstrated rather than argued. test_validate.py part 2 now constructs the hazardous per-inverter config, asserts that the OLD proxy is still green on it, and then asserts the new check refuses it. Against release/1.0's validator the updated sim scenario reports: "validate_startup_config() ACCEPTED a configuration in which 3 declarations reach the ONE actuator number.goodwe_setpoint ... the old proxy reports 1 write key(s) on this same config and is therefore GREEN on it". THE WRITE PATH IS NOW GATED ON THE RECORD ========================================= Controller.write_refusal() asks Allocation.authorises() whether the single allocator placed exactly this number, for this unit, on this tick. write_setpoint() refuses anything else. A second writer with arithmetic of its own cannot satisfy that - its number appears in no record - so it has to file a Claim and go through resolve(), which is the one allocator. "Exactly one allocator per shared resource" stops being a convention somebody can forget and becomes something the write path checks. The refusal is a SUBSTITUTION, not a return: 0 W goes on the wire in place of the number nothing stands behind. Returning would leave the inverter latched on its last command with the ESP32 heartbeat still being fed - holding an unauthorised setpoint quietly, which is the outcome the gate exists to stop. AC 1 is "refused at the write path, not merely logged", and test_clamp.py part 7a grades the device's writes, not the log. The gate sits BELOW the non-finite coercion and ABOVE the clamp. Below, because a nan is loss of input and rule 1 has already turned it into 0 W. Above, because the question is who chose this number, and the answer is only meaningful about the number that was asked for - gating the CLAMPED value would refuse exactly the cycles SAFETY-03 exists for, since a clamped write differs from the allocator's share by construction. Mutation M7 moves the gate below the clamp and turns nine checks red, part 6c among them. A STALE RECORD CANNOT BE REPLAYED ================================= Every Allocation now carries the `tick` it was made for. It is handed IN by the caller - plan(..., tick=) - rather than counted inside the allocator, because test_alloc.py part 10 grades that the module holds no state at all, and a serial number incremented in there would be the cached-across-ticks thing the envelope rule forbids wearing a different hat. Controller.alloc_tick advances ONCE PER RESOLUTION, not once per loop iteration, and both halves are load-bearing: * a new allocation makes the previous record stale immediately, so a record whose envelopes have moved cannot authorise a write. That is the replay half (AC 2), graded by capturing a real record, running another real cycle, putting the old one back and observing refusal. * the "inputs missing, not yet stale" hold returns BEFORE the allocator runs, exactly so one dropped meter reading does not zero the inverter, and the heartbeat re-affirms self.target through this same gate while it does. A tick incremented per loop iteration would refuse that heartbeat and turn the hold into the outage it exists to prevent. Graded. An UNSTAMPED record (tick None) authorises nothing: "I cannot say when I was made" fails closed rather than reading as tick zero. THE FAILSAFE CANNOT DEADLOCK BEHIND THE GATE, AND HERE IS WHY ============================================================= write_setpoint() is also how layer 2 commands 0 W. cycle()'s two refusal branches substitute a 0 W failsafe and write it like any other cycle; shutdown() and the operator's stop button do the same. EVERY ONE OF THOSE RUNS WHILE THE ALLOCATOR HAS REFUSED - ok is False and there are no shares - so a gate that asked the allocator's permission to command zero would block the safety write behind the very component whose failure the safety write answers. A gate that can do that is worse than the hole it closes. Resolved by authorising 0 W unconditionally, and on the VALUE rather than on a failsafe=True flag: a flag is an assertion by the caller, which is exactly what a second writer would pass; the value is a fact about what will be written. The argument that this is safe and not merely convenient is that the hazard §1.5 names is N-times loop gain from BROADCAST - handing every inverter the same target so each supplies it in full (allocator.py's opening argument; TEST-19 measured a permanent 4000 W p2p limit cycle at gain 1.0 with no guard firing). At S = 0 the broadcast and the correct allocation COINCIDE: a_i = 1 for every unit gives every unit 0 W, which is exactly its share. There is no over-command to make at zero. Said at the line, in write_refusal(). test_clamp.py part 7c drives it end to end through the real cycle() and the real REFUSE_EMPTY branch: the allocator refuses every tick, the device still sees 0 W, and the action log says ARBITER: REFUSED rather than WRITE REFUSED so the two failures stay distinguishable to whoever has to debug them. unmet_w IS STILL NOT ok ======================= The gate reads ok and the unit's share and never unmet_w. allocator.py:179 says why: an allocation that could not place every watt is still safe to write, and fusing them would let one saturated inverter park the whole site. Graded on a record with 3800 W unmet and every share legal - mutation M5 adds `or self.unmet_w` to the gate and that check goes red. THE CONFIG CHECK, REPLACED RATHER THAN DELETED ============================================== actuator_writers() counts WRITERS PER ACTUATOR instead of option keys. It walks the options TREE rather than looking for a container called `inverters`, because CFG-04 has not chosen that name yet and a census that guessed wrong would go green while two writers existed - the same defect one step along. Entity ids are folded to lower case so a second writer cannot be smuggled in as Number.X versus number.x. Two inverters with two entities are ACCEPTED - that is what the per-inverter roster is for, and a validator that refuses the feature it guards is one somebody switches off. Two declarations aimed at ONE entity are refused, naming the actuator and every writer pointed at it. That includes the case an author will meet first - keeping setpoint_entity as A's and listing A again - because "they happen to be the same entity, so it is fine" is precisely the reasoning that makes two writers invisible. ACTUATOR_WRITE_KEYS survives ONLY as the enumeration a census needs, with its guarantee comment deleted; nothing asserts on its length any more. Keeping the name is also what stops admin/goodwe's current two_writers scenario failing on an AttributeError between the two merges. WHAT NEITHER CHECK OBSERVES, SAID OUT LOUD ========================================== The census sees CONFIGURED writers. A second write path added in CODE - a hass.set_number() call next to some new strategy - declares nothing and is invisible to it, exactly as it was invisible to the tuple. That half is not closed at startup and cannot be; it is closed at runtime by the record gate, which does not care what the options look like or who called the method. The two checks are deliberately different kinds, and only the runtime one survives a schema this file has not seen yet. EVIDENCE ======== * Full 18-file suite green on a purged tree (tools/mutation_run.sh). * Verified by breaking it - seven mutations, each on a purged tree, each reverted and re-verified: M1 remove the gate -> test_clamp red (4 checks) M2 make the zero exit an amnesty -> test_clamp red (12 checks) M3 drop the tick comparison -> test_clamp red (5 checks) M4 delete the census refusal -> test_validate red (3 checks) M5 fuse unmet_w into ok -> test_clamp red (1 check) M6 freeze the tick on the live path -> test_clamp red (2 checks) M7 move the gate below the clamp -> test_clamp red (9 checks) * AC 5: no existing check was weakened. The two edits to test_clamp.py are fixtures, not assertions - parts 5 and 6c call write_setpoint() directly with a number nothing allocated, which is now refused, so they say where their number came from. Every assertion in both parts is unchanged, and part 7f asserts that across part 3's sixty real cycles the gate refused zero writes. * admin/goodwe's TEST-19 norig scenarios (planner_binding, two_master_plant, two_master_broadcast) still pass against this tree - they import the add-on's control law across the repo boundary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>