Compare commits

...
21 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 3de4453956 INSTR-01: match the finding KIND, because M12 was not holding this file to its own rule
checks / unit (pull_request) Successful in 7m7s
Review APPROVED f6f3f22 plainly. Three LOW notes, all applied - the first is a
real gap and the other two are one line each.

⚠️ M12's ASSERTION WAS KIND-BLIND. It matched on the key and ignored the
finding kind, so renaming `diff()`'s `"VANISHED STORE KEY"` to
`"GONE STORE KEY"` left M12 GREEN - review measured it. A vanished latch
quietly downgraded to a softer heading is exactly what part 3 of this same file
requires every mutation to be graded against ("the SPECIFIC finding it should
produce"), so the file was not holding itself to its own doctrine. Inherited
from SAFETY-44's original M12, not introduced here, and fixed now:

    ("VANISHED STORE KEY", "unit/shed/sign_tripped") in _m12_new

Driven: the kind rename is exit 1 and M12 is among the reds where it was green
before, and C5's removal still turns M12 red, so the strengthening did not cost
the discrimination it exists for.

C4 IS NAMED IN THE CLAUSE MAP. It has no 5e row deliberately - part 3's M9
reaches `vanished_unit is not None` end-to-end and a synthetic row would
duplicate that rather than add separability - but C5 lives in the same `if`,
and listing one guard while omitting the other reads as an oversight rather
than a boundary. Review's own recommendation was not to add the row.

AND THE PRINTED SET SAYS WHAT IT LOSES. The finding regex captures the first
TOKEN of each text, so two findings of one kind collapse into one tuple; the
line now says "first token only" so nobody reads it as an inventory. Harmless
for M12 - the difference is one-directional and the pair it asserts on is exact
- but the display was lossier than it looked. Unused `_m12_out` dropped.

28/28 files pass. `rehearse_roster.py` is untouched by this round; review
confirmed its AST is identical between 91b0d5e and f6f3f22, so every
measurement made against the shipped rule still stands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-09 00:58:03 +02:00
glenn schrooyenandClaude Opus 5 f6f3f227c1 INSTR-01: restore M12 on the leg I generalised past, and measure the clause that had no row
checks / unit (pull_request) Successful in 7m6s
Review found two things this PR asserted about itself that were false. Both are
mine and both are worse than the defect the card was filed for.

⚠️ "NO FIXTURE CAN RESTORE IT, MEASURED BOTH WAYS" WAS NOT TRUE, AND TWO
MEASUREMENTS ARE NOT AN IMPOSSIBILITY PROOF. I deleted M12 - SAFETY-44's
working end-to-end control for the off-roster narrowing - on the argument that
the provenance clause made it unrestorable. The structural claim behind it,
that `run()` builds both snapshots from one state file so "already latched" and
"made by this transition" are incompatible, IS TRUE OF SAME-STEP AND FALSE OF
STAGED: `snapshot_over()` starts the after-leg over the file the BEFORE-leg
wrote, so a transition-made destination is available there. Both arrangements I
measured were same-step shaped. Review found the third.

RESTORED, and it discriminates with all six clauses in:

    staged leg, legacy+shed, shipped     _m12_new = VANISHED unit/shed/sign_tripped
    ...C5 narrowing removed              _m12_new = []          ⚠️ suppressed

⚠️ AND MY FIRST RESTORATION DIDN'T DISCRIMINATE EITHER, for a reason worth
recording: `data.pop(k, False)` RETURNS the value, so shed lost the KEY and
kept the LATCH - `_grade_store_migration()` saw `on_roster_latched`, never
re-armed garage, and there was no transition-made destination. The finding was
reported whether or not C5 existed and the control looked restored. `and False`
is the difference between "the key is gone" and "the LATCH is gone", and only
the second reaches the re-arm this arrangement needs.

⚠️ WHAT ACTUALLY BROKE WAS M12's exit-0 PRECONDITION, NOT M12. A legacy-shaped
fixture is never clean. That precondition is the one SAFETY-44's own commit
called "belt-and-braces, load-bearing only if the exit-0 requirement is ever
relaxed", saying what closed the gap is `_m12_new` BEING A SET DIFFERENCE - and
a stable dirty baseline is fine for a set difference. So I deleted a control on
the strength of a precondition the same commit had already called not
load-bearing. That is removing coverage rather than failing to add it, which is
the worse of the two directions.

⭐ AND THE BARE-KEY CLAUSE HAD NO ROW AT ALL. Review deleted
`k.rsplit("/", 1)[-1] == bare` and the whole 28-file suite stayed GREEN. 5e
mutated five clauses; the comprehension carries more, and this one is
load-bearing: a vanished SAFETY-02 `sign_tripped` was suppressed by a SAFETY-42
`delivery_tripped` on a roster unit - a DIFFERENT FAILSAFE's key. The rule's own
comment says "THE TEST IS NOT 'the name appears somewhere'. It is: the SAME bare
key", and nothing measured that sentence. `unit/*/delivery_tripped` is
machine-read, so it is reachable on any real two-latch store. R7 added; C2 now
flips exactly R7.

All six clause-removals now flip exactly their own row:

    C1 machine_read   -> ['R6-machine-read']    C5 off-roster  -> ['R2-narrowing']
    C2 same bare key  -> ['R7-bare-key']        C6 injectivity -> ['R5-injectivity']
    C3 same value     -> ['R4-value']           C7 provenance  -> ['R3-provenance']

THREE DANGLING CITATIONS TO A CONTROL THIS PR HAD DELETED, one of them in the
shipped tool eight lines below the paragraph condemning exactly that ("a
citation is the worst possible carrier, because it reads as having been
checked"). The tool no longer names a check; it states the property - every
clause has a row, and each removal flips its own.

A STALE SAFETY-44 BLOCK saying both fixes were "REVERTED OUT OF THIS CARD,
because THIS SUITE CANNOT MEASURE EITHER ONE" - describing a tree this PR
replaced. Rewritten as a map of where each clause is now measured. A comment
that outlives its subject is the same defect as a citation that outlives its
check.

AND A SELF-CONTRADICTION about the driver, in the file and in the last commit
message: "THE DRIVER IS WRITTEN INTO THE TREE UNDER TEST" eight lines above
"THE DRIVER GOES IN A TEMPDIR, NOT IN THE TREE". The code does the tempdir; the
first sentence stated the read-only-CI bug the same commit says it fixed, as if
it were the design. It now says the driver IMPORTS the tree and is WRITTEN to a
tempdir.

ONE HOLE REMAINS OPEN AND IS STATED AS UNMEASURED: attribution. `sorted()`
decides which vanished key claims a destination, so a deleted key can sort
first and be named as the one that migrated while the key that really moved is
reported VANISHED. Review measured that `sorted(` -> `list(` leaves the suite
green - every 5e case has at most one candidate, so no row can see ordering.
Still exit 1, so a wrong sentence rather than a hidden loss. Recorded rather
than left looking guarded.

28/28 files pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-09 00:29:00 +02:00
glenn schrooyenandClaude Opus 5 91b0d5e4dc INSTR-01: grade the suppression rule at the level it is written at, one case per clause
checks / unit (pull_request) Successful in 7m4s
SAFETY-44 produced THREE inert controls for this one rule, each inert
differently, and split the two remaining fixes out rather than ship them
unmeasured. This is that card, and the first thing it did was explain why the
end-to-end route could never have worked.

⚠️ `moved_to` IS ONE COMPREHENSION CARRYING FIVE CONDITIONS WITH FIVE DIFFERENT
REASONS TO EXIST, AND `run()` BUILDS BOTH SNAPSHOTS FROM ONE STATE FILE. So
"the destination was already latched" and "the destination was made by this
transition" are the same file answering two incompatible questions: the
arrangement that reaches one forecloses the other. That is why rebuilding a
fixture to reach clause N kept moving the others, and it is not a fixture
problem.

⭐ SO THE RULE IS GRADED WHERE IT IS WRITTEN - `diff()` on constructed
Snapshots, ONE CASE PER CLAUSE. Six rows, and on the SHIPPED tree before any
fix they already separated the two known defects:

    R1 rename (legitimate)   migrated                      correct
    R2 narrowing             vanished                      correct
    R3 provenance            MIGRATED                      ⚠️ the defect
    R4 same value            vanished                      correct
    R5 injectivity           BOTH migrated                 ⚠️ the defect
    R6 machine_read          vanished                      correct

The two one-line fixes SAFETY-44 reverted are back, and R3/R5 flip while
R1/R2/R4/R6 do not - so the legitimate rename is still suppressed and this is
not an over-correction.

⭐ AND EACH CLAUSE REMOVAL FLIPS **EXACTLY ITS OWN ROW**, which is the
acceptance criterion this card was filed with:

    C1 machine_read  removed -> ['R6-machine-read']
    C3 same value    removed -> ['R4-value']
    C5 off-roster    removed -> ['R2-narrowing']
    C6 injectivity   removed -> ['R5-injectivity']
    C7 provenance    removed -> ['R3-provenance']

A removal that flips nothing is an unmeasured clause; a removal that flips
someone else's row means the cases are not separable after all. Neither
happened.

⚠️ AND M12 IS DELETED, WHICH IS THE MOST USEFUL THING THIS CARD FOUND. M12 was
SAFETY-44's end-to-end control for the narrowing and it was a REAL control -
red when the narrowing was removed. Adding the provenance clause disarmed it,
and NO FIXTURE CAN RESTORE IT, measured both ways: with the sibling latched in
its own right, C7 rejects the destination before C5 is consulted, so the
mutation is reported whether or not C5 exists; making the destination
transition-made means a flat latch, which makes the shipped run dirty and
breaks M12's own precondition. The second half of the card's acceptance
criterion - "M12 still turns red with the new clauses in" - is therefore
UNSATISFIABLE, and the honest answer is to remove the control rather than keep
one that has become inert. R2 measures C5 strictly better. Part 5 keeps the
end-to-end coverage; discrimination lives in 5e.

`state-both-latched.json` goes with it. Nothing referenced it once M12 was
gone, and the first draft of that paragraph said it was "kept, because 5e's
R2/R3 are derived from the arrangements it represents" - a nice sentence and
not a reference. An unused fixture a comment calls load-bearing is an inert
check one directory over.

TWO THINGS THE DRIVER GETS RIGHT ON PURPOSE:
  - it is written to a TEMPDIR, not beside the tool. `checks / unit` mounts the
    checkout READ-ONLY, so writing into the tree works on a dev box and is
    `OSError: Read-only file system` in CI - green for whoever wrote it and red
    on every PR. Caught by running it in the check image before committing.
  - the TABLE stays in the test file and the driver goes into the tree under
    test, so a mutation to the tool cannot also move the answer it is graded
    against.

28/28 files pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 23:51:35 +02:00
admin 806390d8b1 Merge pull request 'SAFETY-44: the latch migration takes the roster, and the guard stops being defeated by the key the defect wrote' (#71) from SAFETY-44 into release/1.0 2026-09-08 19:16:32 +02:00
glenn schrooyenandClaude Opus 5 775f3a2e82 SAFETY-44: keep attribution in-tree, add the third clause, and correct a claim I made for my own precondition
checks / unit (pull_request) Successful in 6m56s
Review APPROVED f109153 and the split. Three asks, all applied, plus a
correction it caught in the round-4 commit message.

C STAYS IN-TREE. Attribution is a third property of the same comprehension -
`sorted()` decides which vanished key wins a destination, so a build renaming
`inverter-b` -> `garage` while DELETING `inverter-a` names the deleted key as
migrated and the one that really moved as VANISHED. A reader of the disclosure
paragraph wants it beside the other two rather than only in Trello. One
sentence, same block.

AND A FOURTH CLAUSE REVIEW MEASURED THIS ROUND: `and after.data.get(k) ==
value` survives the suite and is load-bearing. Without it the rule degenerates
to BARE-NAME matching - a deleted `unit/old-x/sign_reason` was reported as
having migrated into a `sign_reason` holding a COMPLETELY DIFFERENT SENTENCE,
and essentially every off-roster vanished `unit/*` key would be suppressed.
Added to the paragraph and to INSTR-01, so all of `moved_to` is covered by one
fixture design rather than two-thirds of one.

⚠️ AND THE POINT IS THAT THEY ARE FOUR CLAUSES OF **ONE COMPREHENSION**, WHICH
IS STRUCTURAL AND NOT A LIST OF CHORES. Four conditions, four different reasons
to exist, one fixture set that can reach only some at a time - so rebuilding a
fixture to reach clause N moves the others. That is exactly how round 4's
disarming happened, and it is what INSTR-01 has to design against rather than
patch around. Review called this the sharpest thing found in four rounds and it
is a fact about the function, not about the two clauses that exposed it.

⚠️ AND I OVERCLAIMED FOR MY OWN PRECONDITION. The round-4 message said the
strengthened M12 precondition was needed because "the exit code alone cannot
say the second thing". Review checked: on a clean run the finding regex matches
nothing, so the second conjunct is IMPLIED by the first today. It is
belt-and-braces, load-bearing only if the exit-0 requirement is ever relaxed.
WHAT ACTUALLY CLOSED THE FIRST DRAFT'S GAP IS `_m12_new` BEING A SET DIFFERENCE
- the mutation must ADD a finding - and that is the check the narrowing's
removal turns red. The wrong half was credited, and the comment now says which
half does the work.

Nit: the fixture's trailing newline and two backticks, lost last round.

`rehearse_roster.py` is still byte-identical to d1b7e3f - this round touches
only the test file's disclosure paragraph and the fixture's comment. 28/28 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 19:02:59 +02:00
glenn schrooyenandClaude Opus 5 f109153216 SAFETY-44: ship only what this suite can measure, and split the two clauses it cannot
checks / unit (pull_request) Successful in 6m58s
⚠️ REVIEW FOUND THE INJECTIVITY CHECK INERT IN THE SAME WAY M12's FIRST DRAFT
WAS, three paragraphs below the comment diagnosing it. Removing the whole
mechanism left 28/28 GREEN: part 5's two destinations are `sign_reason` and
`sign_tripped`, DIFFERENT bare keys, which can never contend. The
`len(_dests) >= 2` precondition was true and established nothing - "not
asserting over an empty list" was correct, "this run can exercise injectivity"
was not.

Review also measured a second, narrower silent loss: `moved_to` accepts a
destination regardless of whether the TRANSITION put the value there, so a
roster unit already latched in its own right is a free suppression slot. A build
deleting `old-a` while renaming `old-b` -> `shed` reported exit 0, NO FINDINGS,
with `old-a` "migrated" into a key that already held True.

⚠️ I WROTE BOTH ONE-LINE FIXES AND HAVE REVERTED BOTH, because THIS SUITE
CANNOT MEASURE EITHER. Three attempts, three different ways of proving nothing:

  1. assert injectivity over part 5's real destinations
     -> passes with the entire mechanism deleted (non-contending bare keys)
  2. build a dedicated fixture for the two-claimant case
     -> discriminates NOTHING: both snapshots read one state file, so nothing
        vanishes and there are no migrated lines with the clause or without it
  3. rebuild M12's fixture so the destination is transition-made, to reach the
     provenance case
     -> ⚠️ DISARMED M12 FOR THE NARROWING. All three clause-removals came back
        GREEN at once, and the control I had measured RED last round was gone.

⭐ (3) IS WHY THIS IS A SPLIT AND NOT A JUDGEMENT CALL. A later fix to the same
function silently disarmed the control for an earlier one, and I saw it only
because I re-drove the removal rather than trusting the suite's exit code.
Shipping two more unmeasured clauses into the rehearsal instrument, on a card
whose entire subject is checks that cannot reach what they name, would be the
fourth inert artifact in one PR. They go on their own card, with review's
measurements as the starting evidence and the fixture built BEFORE the fix -
which is the ordering that worked for M12 and failed every time I inverted it.

⚠️ AND THE SPLIT IS SAFE, WHICH IS WHY IT IS AVAILABLE. Both remaining holes are
in the REHEARSAL TOOL, not the controller. The round-2 narrowing guarantees a
suppressed key is always OFF the roster, so no roster unit loses protection;
what is left is rollback-view/history mis-reporting on a tool an operator runs
before commissioning. Nothing here can disarm a latch on a live site, and
inverter B goes in on ~2026-09-12. Review offered this split explicitly.

M12's PRECONDITION IS STRENGTHENED, because the exit code could not carry it.
It now requires the shipped tree to be clean AND to not already report the
finding the mutation exists to produce. The first draft passed on exactly that
gap: the fixture's own litter made the run red and nothing separated the two.

Re-driven after the revert, on the round-3 fixture:

    shipped                                   exit 0
    `vanished_unit not in after.units` gone   ⚠️ exit 1

28/28 files pass. The controller half is unchanged since d1b7e3f and review has
no further concerns about it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 18:44:38 +02:00
glenn schrooyenandClaude Opus 5 f6d7bfcb04 SAFETY-44: give the moved-not-lost branch a control that reaches it, and make the move injective
checks / unit (pull_request) Successful in 7m38s
⚠️ THE CONTROL I WROTE LAST ROUND WAS INERT, AND REVIEW MEASURED IT. M12 ran its
mutant over `state-legacy.json` and the findings diff against the UNMUTATED tree
was EMPTY. It "passed" because that fixture's STAGED leg is already exit 1 on
pre-existing litter, and `"migrated  unit/shed/" not in out` was trivially true
because a pre-DEV-02 FLAT store has no shed latch to lose - after migration only
the FIRST roster entry is ever latched.

The decisive measurement is the one it could not make: reverting the
`vanished_unit not in after.units` narrowing - restoring exactly the rule that
hides a real latch loss - left 28/28 GREEN.

⚠️ SO IT WAS HABIT 4 WEARING THE CLOTHES OF ITS OWN FIX, AND WORSE THAN THE
"NO CONTROL" IT REPLACED, because it reads as checked. That is this commit's
own argument about the M3 mis-citation, turned on the thing I wrote to answer it.

⭐ THE FIXTURE CAME FIRST THIS TIME. `state-both-latched.json` is a store at
SCHEMA_VERSION with `unit/garage/*` AND `unit/shed/*` both latched by ONE site
event, so the reasons match too - the arrangement in which same-value
suppression is most dangerous, and the one no existing fixture could produce.
M12's mutant then makes ONE roster unit's latch key VANISH while its sibling's
survives at the same value:

    shipped tree, both-latched fixture      exit 0, no findings
    one unit's latch key deleted            exit 1, ADDS
                                            VANISHED STORE KEY unit/shed/sign_tripped
    narrowing reverted                      ⚠️ RED  (was 28/28 green)

And it asserts the PRECONDITION - the fixture is clean on the shipped tree -
plus that the mutation ADDS findings rather than coinciding with ones the
fixture already had. An empty diff there is exactly what made the last draft
inert, and nothing said so.

⭐ AND THE MOVE IS NOW INJECTIVE, which is the narrower hole review found still
open. `moved_to` is "any machine-read key with the same bare name and the same
value", and `DEFAULTS["sign_tripped"] is False` means `True` is the ONLY
non-default value a latch can hold - so N vanished keys could all claim the SAME
destination, by construction rather than by coincidence. Measured on a rename
carrying `inverter-a` -> `garage` correctly while DROPPING `inverter-b` -> `shed`:

    migrated  unit/inverter-a/sign_tripped = True -> unit/garage/sign_tripped
    migrated  unit/inverter-b/sign_tripped = True -> unit/garage/sign_tripped

Zero store-key findings, on a run where `shed` came up UNLATCHED. Two keys
cannot both have become one key. `diff()` claims each destination once.

⚠️ AND THE CHECK FOR IT IS ANCHORED TO A RUN THAT ACTUALLY PRODUCES MIGRATED
LINES. A dedicated mutant was written first and asserted over an EMPTY list -
vacuous, passing, indistinguishable from working, the same defect twice in one
sitting. These fixtures cannot produce two claimants (`migrate_unit_keys()`
moves FLAT keys only, so one flat latch yields one migration), so rather than
build a fiction to grade, injectivity is asserted over part 5's real migrations,
with a PRECONDITION that there are at least two of them. THE TWO-CLAIMANT
ARRANGEMENT IS A STATED RESIDUAL: bounded in `diff()` by construction, not
measured end-to-end.

Finding 4, and my correction to it was also wrong. The comment cited "line 315"
and "two hundred lines below"; the fix I wrote said "~54 lines down", which was
review's distance to the WRONG line, carried to a new referent without
re-measuring - inside the sentence apologising for a wrong number. Recomputed at
5e382f5: 327-261 = 66, 327-252 = 75, and 54 was neither. There is no distance
now, because a distance is a coordinate and the sentence already names the thing.

And the residual gains its second half, which review noticed and I had not: the
reason re-armed from is the FLAT mirror, carrying whatever tripped LAST, so this
path can print one inverter's evidence under another's name. The latch is right
and the explanation may not be. `test_maintenance.py`'s check that the reason is
not the removed unit's is guaranteed by that fixture's flat string, NOT by the
code, and now says so.

28/28 files pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 18:08:16 +02:00
glenn schrooyenandClaude Opus 5 d1b7e3f113 SAFETY-44: decide per key, because one shared flag let an unrelated latch veto the repair
checks / unit (pull_request) Successful in 6m58s
⚠️ REVIEW DEFEATED THIS CARD'S OWN FIX WITH ONE ADDED KEY, and the project's own
instrument agreed on the unmodified tree. The guard classified each key and then
wrote ONE site-wide `namespaced_latched` for BOTH "on the roster" and
"off-roster but not UNIT_A", so a single latched key belonging to a unit the
operator had removed from `inverters:` vetoed the repair of the misdirected
`inverter-a` latch beside it. Measured, roster ['garage','shed'], store stamped
so the migration fix cannot reach it:

  sign_tripped + unit/inverter-a/sign_tripped     -> [('garage', True),  ...]
  ...AND unit/old-shed/sign_tripped               -> ⚠️ [('garage', False), ...]

A site DRIVING on a state file that reads `sign_tripped: true` - the sentence in
this card's own first paragraph - and `tools/rehearse_roster.py` printed
`LATCH CAME UP DISARMED` on it, exit 1, against shipped code. It matters more
than a normal hole because this guard is, as main.py says, the ONLY repair path
for an install already stamped by 0.9.0.

⚠️ AND IT IS INSTANCE #17 OF HABIT 4, IN THE EXTENSION MUTATION.md ADDED TWO
DAYS AGO. The two fixtures written for this card each hold ONE arrangement -
`_staged_store` has only `inverter-a`, `_other_store` only `inverter-b` - so the
flag was never written twice with opposite intents in a single store. A per-key
check whose keys have never disagreed WITHIN ONE STORE is a check over whichever
key came last. Three variables now, one per case, and a fixture holding BOTH.
Four cases driven: stranded-only repairs, +1 unrelated repairs, +2 repairs,
off-roster-only does NOT (which is DEV-02's control, still green).

⭐ AND THE MOVED-NOT-LOST RULE HID A GENUINE LATCH LOSS. The match was bare key
plus value over the whole of `machine_read`, with no identity - and
`DEFAULTS["sign_tripped"] is False`, so the ONLY non-default value a latch can
hold is `True`. Any surviving latched roster unit therefore suppressed any other
unit's vanished latch. Review measured a build that drops `unit/shed/sign_*`
while `unit/garage/sign_*` survives, both latched by one site event so the
reasons matched too:

    none in LEG SAME-STEP: nothing vanished, nothing stranded ...
    NO FINDINGS in the rehearsed transitions            exit 0

A roster unit's persisted SAFETY-02 latch deleted, transition reported clean.
The legitimate case is a RENAME - the key left `inverter-a`, which is not on
this roster, and arrived under `garage`, which is - so the vanished key's own
unit must be OFF the roster. A key whose unit is still on the roster has not
been renamed, it has been lost, and no amount of the same value elsewhere makes
that untrue.

⚠️ AND THE BOUND I CITED FOR IT DID NOT EXIST. Both the comment and the last
commit message said "what M3 (delete the flat rollback view) measures". M3 is
not that mutant - it drops `sign_reason` from `LEGACY_UNIT_KEYS` - and the one I
meant, M9, vanishes only FLAT keys, so neither can satisfy the `unit/` gate the
rule sits behind. NEITHER EXERCISED THE BRANCH; it had no negative control at
all, in a card whose subject is checks that cannot reach the failure they name.
A citation is the worst carrier for that, because it reads as having been
checked. M12 is written for it and driven: a roster unit that stops restoring
its latch is exit 1, and the suppression does not swallow it.

`misdirected` IS UNREACHABLE FROM THE CALL SITE, and the claim that it was a
benefit is withdrawn rather than restated. `unit` is `roster_names(opts)[0]` and
`build_roster()` names its first entry from the same list, so `unit` is a member
of `roster` by construction; review measured it - dropping the `roster` argument
inside `migrate_unit_keys()` survives 28/28. The argument stays for the reason
`_verify_moves()` gives for its own `roster=None` branch, it is what a NEW
caller gets - and a guard nothing exercises is a guard nobody knows works, so it
is now exercised at the function's own level: a wrong `unit` comes back
misdirected, the marker is not stamped, and the same call with a roster name
stamps it. Dead `if _roster_names else UNIT_A` removed; `roster_names()` never
returns [].

THE OPERATOR MESSAGE IS ASSERTED. Review deleted it whole (`if stranded:` ->
`if False:`) and 28/28 stayed green - `stranded` had no other consumer, so the
list existed only to print a line nothing checked, and that line is the entire
operator-facing half of the repair. Now: exactly one SAFETY-44 line at ERROR,
naming the stranded key and the roster it was measured against.

A wrong line citation removed: the call-site comment said "line 315's comment"
and "two hundred lines below", and both were wrong (315 is the CFG-03 defaults
note; the comment meant is ~54 lines down). Cite the thing, not its coordinates.

⚠️ AND WHAT 5b/5c ACTUALLY PIN IS RECORDED, because review is owed the
distinction: `mutant_tree()` asserts its target exists, so EDITING the guarded
line makes the file red at the ANCHOR before any behaviour is measured - which
looks identical from the exit code and proves much less. It happened twice while
writing this commit. The behavioural coverage lives in test_maintenance.py
section 13 and test_inverter.py, which build stores rather than strings.

Fixture strings no longer say "measured" - they are synthetic, and the repo's
other fixture carries an explicit SYNTHETIC header.

28/28 files pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 17:08:41 +02:00
glenn schrooyenandClaude Opus 5 5e382f52f6 SAFETY-44: the latch migration takes the roster, and the guard stops being defeated by the key the defect wrote
checks / unit (pull_request) Successful in 6m56s
⚠️ A DISARMED SAFETY-02 GUARD ON AN INSTALL WHOSE STATE FILE SAYS IT IS
PROTECTED. `Controller.__init__` called `migrate_unit_keys(store, UNIT_A)` - a
hardcoded constant - while its sibling `migrate_maint_keys(store, unit, roster)`
takes the roster and refuses a write no machine reads. On a store that ran
pre-DEV-02 flat and declares `inverters: [garage, shed]`, the latch moved to
`unit/inverter-a/sign_tripped`, `_verify_moves()` read it back under the name it
had been HANDED, was satisfied, and stamped the marker. Not one inverter came up
latched, and the next start skipped the migration too.

That is the MAINT-05 defect verbatim, still live in the function whose own
docstring describes it, and `_grade_store_migration()` did not catch it: its
"is anything namespaced latched" test was satisfied BY THE MISDIRECTED VALUE
ITSELF.

⚠️ THE REASON IT WAS LEFT WAS A HALF-TRUTH. "There is no roster yet - the
`Inverter`s restore their latch in their constructors." The KEYS must be written
before the `Inverter`s read them; the NAMES do not come from the `Inverter`s.
`roster_names(opts)` reads them off the options before anything is constructed -
which is exactly what it already existed to do for `MqttPublisher` - and returns
`[UNIT_A]` on the flat shape, so today's upgrade path is byte-identical.

TWO EDITS, AND THEY DO DIFFERENT JOBS. Measured per leg by reverting each one:

                          SAME-STEP leg          STAGED leg
  both edits (shipped)    clean                  latch armed, litter reported
  guard reverted          latch armed            ⚠️ DISARMED
  call site reverted      latch armed, litter    latch armed, litter

So the GUARD carries the safety and the CALL SITE carries the hygiene and the
withheld marker. The table is in test_rehearse.py part 5 because a reader who
assumed the roster-aware migration re-arms the latch would delete the guard as
redundant in a tidy-up and take the STAGED column with it - and STAGED is what
"upgrade now, declare the roster later" produces, which is the path an operator
actually takes. Only the guard reaches an install already stamped by 0.9.0.

⚠️ AND "OFF THE ROSTER" ALONE WAS TOO BLUNT, WHICH test_inverter.py CAUGHT. The
first draft treated every off-roster latch as litter. In DEV-02's restart case
the store carries `unit/inverter-b/sign_tripped = True` from B's own trip while
the roster being graded holds only A - and the blunt rule re-armed a HEALTHY A
off the flat key B's trip had mirrored. DEV-02's defect rebuilt inside its own
guard, which is what the paragraph I was overruling had been right to fear.

Narrowed to `UNIT_A`, which is the only name the broken migration ever wrote -
`roster_names()` falls back to a POSITIONAL `inverter-N`, so `inverter-a` is not
a name this code hands to anyone else. A latch stranded under any other name is
a real unit out of today's config and still counts as protection. Residual,
stated in the code rather than left to be found: a site whose own first unit is
NAMED `inverter-a`, latched, then taken out of the roster gets re-armed on
`inverters[0]` - a healthy unit held at 0 W until an operator clears it. The
conservative direction, against a site DRIVING on a file that says it is latched.

⭐ AND FIXING THE CODE EXPOSED A FALSE POSITIVE IN THE HARNESS. With the latch
now landing under `garage`, `rehearse_roster.py` reported VANISHED STORE KEY for
`unit/inverter-a/sign_tripped` three lines above the same value sitting under
`unit/garage/sign_tripped`. A key that moved to a namespace a machine reads has
not vanished - and an alarm that fires on the harmless case is one an operator
learns to dismiss before the real one arrives, which is CONTROL-C's own
argument. The test is not "the name appears somewhere": same bare key, same
value, under a key in `machine_read`, the set built by driving a real start. A
latch that genuinely went missing has no such key and is still a finding, which
M3 (delete the flat rollback view) still measures red.

Test parts replaced deliberately, as both of them asked to be:
  - test_rehearse.py part 5 asserted the defect "as REQUIREMENTS so that a fix
    turns them red". It now asserts the fix, with 5b and 5c as negative controls
    - one per edit - and 5b doubles as the leg-splitter's positive control,
    since reverting the guard is the build where the two legs DISAGREE.
  - test_maintenance.py section 13 was headed "RECORDED, NOT FIXED" and said the
    fixing card "should expect them to go red and should replace them". Replaced,
    plus two cases it could not have had: the already-stamped store, and the
    `inverter-b` negative control for the narrowing above.

28/28 files pass. Not run: nothing - this is host-side only, no rig, no hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 15:36:37 +02:00
admin 46c022c926 Merge pull request 'ARCH-02: rehearse the flat-to-roster switch - and SAFETY-44 re-confirmed by a second instrument' (#70) from ARCH-02 into release/1.0 2026-09-08 15:09:27 +02:00
glenn schrooyenandClaude Opus 5 22ca341459 ARCH-02: assert the invariant, because a fixture cannot enumerate arrangements
checks / unit (pull_request) Successful in 6m55s
Review approved and then attacked part 5b, the control that stands between B1's
defect and a green suite. It got through in three edits:

    A1  delete `END LEG`                        5b CATCHES it        2 FAIL
    A2  ...and reorder the legs                 5b BLIND             1 FAIL
    A3  ...and reword the closing summary       0 FAIL, "all checks passed"
                                                with a splitter that closed
                                                ZERO legs

⚠️ TWO OF THOSE THREE ARE ORDINARY REFACTORS - reordering report legs, rewording
a summary line - and neither reads as touching a safety check. 5b caught the
unclosed splitter only because the clean leg happened to be printed LAST, and
the backstop that saved A2 was a hardcoded transcription of the tool's own
sentence, so rewording that sentence blinded it too.

FIXED BY ASSERTING THE STRUCTURE RATHER THAN ADDING A FIXTURE: a leg may not
open while one is open, and every leg that opens must be explicitly closed.
Driven - deleting `END LEG` now fails on the FIRST edit, whatever the order or
the wording:

    AssertionError: leg 'SAME-STEP' opened while 'STAGED' was still open - the
    tool stopped closing its legs, and a splitter that never closes swallows the
    closing summary into the last leg.

All three attacks include that edit, so one assertion kills the class. Clean
tree unchanged: 28/28, all checks passed.

⭐ AND THE RULE IN MUTATION.md IS EXTENDED, because review's finding is the
general case of it. It said "a per-thing check that has never seen the things
DISAGREE is a total". Part 5b DOES make them disagree, every run, and was still
blind. So:

    ...AND ONE THAT HAS ONLY SEEN THEM DISAGREE IN ONE ARRANGEMENT IS A TOTAL
    OVER THAT ARRANGEMENT. When a fixture cannot enumerate the arrangements,
    assert the invariant they share.

Also recorded, NOT fixed, with the reason: the reference carries `entity_id
unique_id device` and grades all three, but never the discovery TOPIC. Review
measured `homeassistant/... -> ha/...` exiting 0 with no findings while every
announced entity dies. `discovery_payloads()` already returns the topic and
`announced_rows` discards it, so a fourth column closes it - but the prefix is
HA's fixed contract rather than a name this project chooses, so it is not what a
tidy-up rename reaches. Lower LIKELIHOOD than the N1 gap it resembles, not lower
consequence, and it stops being a note the day the topic becomes configurable.

Two stale figures corrected: the tool claimed five migration mutations where
there are nine, and pointed at a DOCS.md heading that does not exist (the real
one is "Before you commission inverter B", verified present at DOCS.md:1577).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 15:01:55 +02:00
Claude Opus 5 9c1a9e0f17 ARCH-02: M7's evidence was filtered out by its own finding kind
checks / unit (pull_request) Successful in 6m56s
The mutation battery prints the distinct findings each mutation caused, matched
on the finding's KIND. The pattern's character class was `[A-Z -]`, which has no
underscore - so `UNIQUE_ID DRIFT:` did not match, and M7, the mutation that
renames `mqtt.BASE`, printed its verdict with no evidence under it at all.

A filter that silently drops the very finding a mutation exists to produce is
the same defect as the rest of this card, at display scale. No behaviour change:
M7 was red before and is red now, and it now shows why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 14:23:35 +02:00
Claude Opus 5 638c213944 ARCH-02 round 2: the pin could not tell one red leg from two, and the tool went
checks / unit (pull_request) Successful in 6m57s
quiet when a flag was forgotten

Both blocking findings were reproduced before anything was changed, and both are
the class this card exists to guard against.

B1. THE PIN WAS A COUNT, AND A COUNT CANNOT SEPARATE TWO ROUTES. The rehearsal
reaches the disarmed latch by two independent paths and prints every finding
TWICE per red leg - once in the leg's block, once in the closing summary - so
ONE red leg satisfied `count(...) >= 2`. Measured: shipped code 4; with
`migrate_unit_keys()` handed the roster's own name (SAME-STEP fixed) 2, PASSES;
with a guard-side fix (both fixed) 0. The check named "NOT THE SAME-STEP PATH
ALONE" reported ok on the build whose only remaining failure IS the staged path.
The tool now prints `LEG <id>` / `END LEG <id>` and the pin requires the finding
in EACH leg by name.

⚠️ AND THE FIRST SPLITTER CARRIED THE SAME DEFECT ONE LEVEL DOWN - it opened on
the marker and never closed, so the last leg absorbed the closing summary and
read red whenever ANY leg was red. Caught by building the one fixture that
separates them. Part 5b is now that fixture, run every time: a tree in which
SAME-STEP is genuinely clean and STAGED is not. A per-thing check that has never
seen the things disagree is a total with extra steps.

⚠️ AND IT CORRECTS A CLAIM THIS CARD MADE. `main.py:250` is NOT the whole fix.
Measured on that exact change: LEG SAME-STEP clean, LEG STAGED still DISARMED -
the staged path stamps `schema_version` on the flat start and the roster start's
migration returns early before the name it is handed matters. DOCS.md now says
so, with the measurement.

B2. A FORGOTTEN FLAG WAS A CLEAN BILL OF HEALTH. Omitting `--live-entities`
printed "nothing orphaned" and exited 0, with `grep -ci` for the words returning
0 - while an UNREADABLE file exited 2. The asymmetry was backwards. Omitting it
now refuses (exit 2); `--no-live-entities` is the deliberate opt-out and carries
a standing banner in the header AND on the exit line, because a report is
skimmed from the bottom.

N1. THE REFERENCE DID NOT CARRY WHAT HA KEYS ON. It held entity_ids only, so
renaming `mqtt.BASE` (every unique_id moves) and changing `DEVICE["identifiers"]`
(HA sees a new device) both came back exit 0 - real orphanings with every
composed id byte-identical. The reference is now three columns
(`entity_id unique_id device`), is loaded on EVERY run including a degraded one,
and M7/M8 pin both. `object_id` is deliberately not a column and its rename is
required to SURVIVE, which is what bounds the four mqtt.py mutations.

Also found while fixing the above, and fixed with its own paired positive: the
VANISHED check did not carry the default/non-default rule the STRANDED check
does, so a pair of migrated DEFAULTS moving namespace read as two findings and
CONTROL-C went red on a CORRECT build. M9 drops non-default keys and must still
be a finding.

N2/N3. DOCS.md withdraws the unmeasured "not recoverable by advancing again" -
its own reasoning cut the other way and nothing here tested a re-advance; the
measured part (eight rows stop updating, history gaps, automations stop firing)
stands. And the byte-identical-input property is `test_rehearse.py`'s assertion,
not the tool's.

FRAMING. SAFETY-44 is ALREADY RECORDED on the merge base - `test_maintenance.py`
section 13, with the "guard is defeated by the very key the defect wrote"
sentence, in a file this card does not touch. DOCS.md now says this is
independent re-confirmation through a second instrument, reachable from an
owner-supplied state file rather than only from a fixture, and not a discovery.

app/ is still untouched. 59 checks in test_rehearse.py, 9 mutations red, 2
required survivors, 3 clean controls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 14:21:34 +02:00
Claude Opus 5 1841416aa4 ARCH-02: put the fresh-install checks in the part they belong to
checks / unit (pull_request) Successful in 6m49s
They were printing under "6. THE REVERSE DIRECTION", which is not what they are
about, and part 2's heading still said two controls when there are three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 13:23:03 +02:00
Claude Opus 5 cb8f800180 ARCH-02: the harness was red on every fresh install, and a third control says so
A store the add-on has never written has no latch and no history. The DEV-02
migration still runs on it - `schema_version` is absent, so `from_version` is 0 -
and it writes `sign_tripped = False` and `sign_reason = ""` under `inverter-a`
before `build_roster()` exists. On a declared roster nothing reads either of
them, so the first draft called both STRANDED and exited 1. Every fresh install
that grows a second inverter would have been reported as a failed transition.

⚠️ THAT IS A DEFECT IN THE ALARM, NOT A CASE TO SUPPRESS, and store.py already
carries the distinction it needed: `MigrationReport.lost` reports a dropped
`sign_tripped=True` and NOT a dropped `sign_tripped=False`, because "absent" and
"never tripped" are the same statement - and because an alarm that fires on the
harmless case is one an operator learns to dismiss before the real one arrives.
The stranded check now makes the same split, on the VALUE and not on the key
name: a key stranded at its default is printed under its own heading, visibly,
and is not a finding; the same key stranded at a non-default still is. SAFETY-44
turns on `sign_tripped = True`, so it is untouched, and a check pins both
halves against each other so the softening cannot widen into a hole.

CONTROL-C is the fresh install, and it is a third clean control rather than a
line in a comment: the shipped code is right about it and the harness must say
nothing. It is the control that found this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 13:20:49 +02:00
Claude Opus 5 018da00b74 ARCH-02: show the mutation evidence, not the tool's own docstring
The mutation battery printed every line of the harness's output starting with a
warning sign, and the harness's header is four paragraphs of them - so each
mutation's evidence arrived buried under "THIS TOOL RUNS THE REAL Controller"
repeated six times, and each finding appeared once per transition leg plus once
in the summary. The filter now matches a finding's KIND and de-duplicates, so
what part 3 prints is the set of distinct findings the mutation caused.

No behaviour change: the same six mutations go red for the same reasons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 13:16:47 +02:00
Claude Opus 5 e06906d65f ARCH-02: rehearse the flat-to-roster switch, and it comes up disarmed
Nothing had ever run the real `Controller` through the transition inverter B's
commissioning performs on 2026-09-12. `tools/rehearse_roster.py` does, over a
COPY of a state file given as an input path, and reports the difference: every
store key that moved, appeared, vanished or was STRANDED under a name no
machine reads; every entity id before and after with orphans named; and whether
the maintenance history MAINT-05 moved once survived being moved again.

⭐ WHAT IT FOUND, AND IT IS SAFETY-44's ANSWER. A store that ran pre-DEV-02 flat
and declares a multi-inverter roster comes up with its SAFETY-02 latch DISARMED
unless the first roster entry happens to be called `inverter-a`.
`Controller.__init__` hands `migrate_unit_keys()` the hardcoded `UNIT_A`, so the
latch lands in `unit/inverter-a/sign_tripped`; a roster of ["garage","shed"]
reads `unit/garage/sign_tripped`, which is absent. And `_grade_store_migration()`
- the guard written for exactly this - sees the migration's OWN misdirected key
satisfy its `namespaced_latched` test and returns without re-arming. Measured,
not argued: the file says `sign_tripped: true`, no inverter is latched, nothing
is held at 0 W. The same state file onto a roster whose first entry IS
`inverter-a` comes up latched, so the finding is attributable to the name.

The staged upgrade reaches it too: the flat start stamps `schema_version`, and
the roster start's migration then takes its early return.

⚠️ NO RUNTIME CODE IS CHANGED HERE. The fix to `migrate_unit_keys()` belongs to
SAFETY-44 reopened as a defect, shown red before it; this commit is the
instrument that shows it red.

THE CHECK CAN FAIL, AND WAS SEEN TO. `test_rehearse.py` establishes two CLEAN
CONTROLS first - two transitions the shipped code gets right, on which the
harness must say nothing - and then breaks the shipped migration and the
entity-id composition six ways in a private copy of the tree, requiring each to
turn its control from green to red WITH THE RIGHT FINDING NAMED. One mutation
is required to SURVIVE, with the reason written down. The synthetic state
carries maintenance history and a legacy-shaped latched `sign_tripped` with no
`schema_version` beside it, because state built by the happy path cannot detect
a migration that only mishandles legacy shapes.

⚠️ THE FIRST DRAFT OF THE HARNESS HAD HABIT 4 IN IT and its own battery caught
it - MUTATION.md's thirteenth instance, third in an instrument. The entity diff
composed both sides with the same build, so renaming the MQTT device moved every
id equally and it reported "no orphans" about a change that orphans everything.
`--live-entities` is the fix: a reference that does not move with the code.

THE RETREAT is supported and does not corrupt state - the flat rollback view
keeps the latch and the first pack's history live - but it orphans eight HA
entities, and the ROUND TRIP is not supported: a maintenance cycle completed
while rolled back is discarded on re-advance. Stated in DOCS.md as a decision.

Fetching /data/state.json, the options blob and the entity list are OWNER STEPS
and are written down. ⚠️ Nothing here was run against real state and nothing
here claims to have been.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 13:15:05 +02:00
admin 37dd0f00cc Merge pull request 'SAFETY-43: delinquency measured against the ask, not against a flat 500 W' (#69) from SAFETY-43 into release/1.0 2026-09-08 05:56:44 +02:00
admin a0d981731c Merge pull request 'SAFETY-31 AC 4: the alarm's attribution was graded by nothing, and a mutation proved it' (#68) from SAFETY-31-attribution into release/1.0 2026-09-08 04:27:16 +02:00
glenn schrooyenandClaude Opus 5 9099ceb1af SAFETY-31: the grid is 42, not 90, and the wrong number was in the failure text
checks / unit (pull_request) Successful in 6m27s
Review reproduced every figure in both PRs except one, and rejected on it.

⚠️ "0 of 90 GAINS x SPLITS combinations produce opposing shares" - the SUBSTANCE
is right and review re-measured it, but the DENOMINATOR is invented. sitesim.py
declares GAINS with 7 entries and SPLITS with 6: the grid is 42. 90 is not even
divisible by 7, so no reading of "GAINS x SPLITS" reaches it. It is almost
certainly TEST-20`s RUN count (84 + 6), which is a different quantity that
happened to be nearby.

⚠️ AND THE WORST COPY OF IT WAS INSIDE THE `PRECONDITION FAILED` TEXT, so the
next engineer to see this scenario go red would have been handed an
unreproducible measurement as their diagnosis - the exact defect class this
campaign exists to correct, in the failure path where it does the most harm.

Corrected in all seven places. At the failure-text site the denominator is now
`len(GAINS) * len(SPLITS)`, computed rather than typed, so it cannot drift from
the grid again - the same fix test_translations.py already took.

Also N3: a missing blank line was going to render the S3 and M19 paragraphs as
one in DECISIONS.

Re-run after the change: `inject.py two_master_circulation` PASS in 35 s, norig,
no rig lock taken. Add-on suite 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 20:13:01 +02:00
glenn schrooyenandClaude Opus 5 295a2aedb6 SAFETY-31 AC 4: the alarm's attribution was graded by nothing, and a mutation proved it
checks / unit (pull_request) Successful in 6m27s
`pos, neg = opposing(...)` -> `([], [])` in main.py's `check_circulating()`
(-102 B) deletes the "Right now X is discharging while Y charges" clause from
the operator notification, and SURVIVED ALL 26 FILES. The commit that shipped
this card claimed "18 mutations, all RED"; the ledger has no entry for the
attribution, and this is that entry.

It is not dead code. Ledger M18 - the whole `await check_circulating()` call
removed - is red on four checks, so the function runs. The three checks on the
notification asserted METER CANNOT SEE, CONTROL HAS BEEN STOPPED and `isolator`,
and never an inverter name. `circulation.py`'s own docstring calls that sentence
the first thing somebody woken by this alarm needs and "impossible to
reconstruct from the number alone".

test_watchdog.py section 6a closes it on a fixture that can tell three failures
apart:

  * the roster is ["garage", "shed", "cellar"] - operator names, none of them
    `UNIT_A`, for test_maintenance.py section 11's reason: on a fixture whose
    names are the module's own constants a check cannot tell the roster from
    the constant;
  * the site is run TWICE, MIRRORED, so the sentence is not symmetric and a
    pos/neg swap is a different red from a deletion;
  * a third pack rests at 5 W, so the floor that stops a pack in standby being
    named as a participant in a 6 kW loop is graded too.

Mutations, on a tree with no __pycache__ mounted :ro into the checks image:

  M20  pos, neg = opposing(...) -> ([], [])          -102 B  RED (2 checks)
  M21  pos and neg exchanged                          +0 B   RED (3 checks)
  M22  opposing(..., min_w=cfg.min_w/2.0) -> 0.0      -25 B  RED (3 checks)

26/26 files green with the block in place.

AND A DISCLOSURE, WHICH IS THE UNCOMFORTABLE HALF. DECISIONS section 3 records
that the 924-run campaign behind `circulation_w: 700` and `circulation_hold_s:
75` used a harness that is not committed in either repository. Two further
sweeps have now been run and no two of the three agree: peaks of 1700 W, 2750 W
and 1048 W, and worst-continuous figures above 700 W of 5.0 s, 15.0 s and 5.0 s.

That is NOT evidence of fabrication and is not reported as any. The committed
sweep measures why the spread is that wide: on one grid, changing only the
plant model and the write-stagger set, the worst legitimate peak is 1048 W, or
2250 W, or 0 W. The plant and the stagger are TERMS of the number. What the
spread does establish is that the claim cannot be adjudicated while the
instrument is missing.

The window argument survives and is now committed - all three sweeps trip zero
times, on 924 + 48 + 840 non-faulty runs. The threshold CURVE does not survive
as stated: `505.0 s at 100 W`, which is what the "knee at 400 W" argument rests
on, is 10.0 s on the committed grid. The curve still falls with the threshold,
so the knee is real in direction; its depth is established by nothing anybody
can run, and this commit says so rather than repeating it. The overclaiming
comment at test_watchdog.py's 1700 W fixture ("the worst single transient the
rig can produce") is replaced with what the fixture can support.

The committed, re-runnable half is admin/goodwe's `two_master_circulation`,
opened as a separate PR against that repository.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 19:08:33 +02:00
18 changed files with 3467 additions and 87 deletions
@@ -149,8 +149,70 @@ each swept across the **whole range `config.yaml` accepts** for the two settings
that decide how fast the site can turn around: `gain` `float(0.05,1.0)` and
`slew_w` `int(50,5000)`.
### ⚠️ THE HARNESS BEHIND EVERY FIGURE ABOVE IS NOT COMMITTED, AND UNTIL 2026-09-07 THAT MADE THEM UNCHECKABLE
**Read this before quoting any number in this section.** The paragraph below
said the campaign harness "is not committed here" and treated that as a
packaging detail. It is not: a figure produced by a script that exists in
nobody's tree cannot be re-run, cannot be reviewed, and cannot be defended when
it is contradicted. It has now been contradicted twice.
| sweep | worst peak | worst continuous | trips |
|---|---:|---:|---:|
| the 924-run campaign below — **harness uncommitted** | 1700 W | 505.0 s at 100 W; 5.0 s above 700 W | 0 / 924 |
| independent reviewer sweep, 2026-09-07, 48 honest reversals | **2750 W** | **15.0 s above 700 W** | 0 / 48 |
| `admin/goodwe` `two_master_circulation`, 840 runs — **committed** | 1048 W | 10.0 s at 100 W; 5.0 s above 700 W | **0 / 840** |
**This is not proof that anything was fabricated, and it should not be reported
as one.** The three swept different grids. What the third one adds is a
measurement of *why* the spread is that wide — on one grid, changing nothing but
the model:
| plant / stagger | worst legitimate peak |
|---|---:|
| modelled plant (lag 10 s, ramp 800 W/s), staggers up to a whole cycle | 1048 W |
| `INSTANT` (lag 0, unbounded ramp), staggers up to a whole cycle | 2250 W |
| `INSTANT`, staggers strictly under a whole cycle | **0 W** |
A factor of 2.1×, and a factor of infinity, between three defensible
descriptions of the same rig. **The plant model and the write-stagger set are
terms of the number**, and a worst case quoted without them is not a
measurement. That is enough to explain the whole spread above without anybody
having invented anything — and it is exactly what cannot be established when the
instrument is missing.
**What survives, and what does not.**
* **The window argument survives, and is now committed.** The claim that
decides `circulation_hold_s: 75` is *"the worst continuous legitimate episode
above 700 W is about one control cycle"*. All three sweeps agree on the shape
of that (5.0 s, 15.0 s, 5.0 s — one to three cycles against a fifteen-cycle
window), and **all three trip zero times**. `two_master_circulation` re-runs
it on every pull request in `admin/goodwe`.
* **The threshold curve does NOT survive as stated.** `505.0 s at 100 W` is the
number the "knee at 400 W" argument for `circulation_w: 700` rests on, and the
committed sweep measures **10.0 s** at 100 W on its grid — 50× smaller. The
curve still *falls* with the threshold (126 of 840 runs cross 100 W against 1
that crosses 700 W), so the knee is real in direction; its depth is not
established by anything anybody can run. **Do not quote 505.0 s.**
* **Nothing about the guard is compromised.** 0 trips in 924 + 48 + 840
non-faulty runs, from three independent grids.
**What would settle it:** the original harness, committed. Until then the
committed sweep is the only figure in this section anybody can check, and it is
labelled as its own grid's rather than as a reproduction.
### Reproducing it
⚠️ **THE COMMITTED VERSION IS `admin/goodwe`'s `sim/inject.py
two_master_circulation`** (`sim/sitesim.py:circulation_findings`, runnable on its
own as `python3 sim/sitesim.py --circulation`, `norig`, ~36 s, graded by
`checks.yml` on every pull request there). It produces the fault with
`plantsim.Inverter.wedge()` — a unit answering its bus and no longer steering its
power stage, which is HW-01 — and sweeps the legitimate family with the
serialised write path staggered up to a whole control cycle. The paragraph below
describes the ORIGINAL campaign, which is the one that is not committed.
The rig lives in `admin/goodwe` (`sim/`) and was used **read-only**; the harness
is not committed here because it imports that repo's `plantsim`/`frame_check`,
which are not present in this one or in CI. What it does is small enough to
@@ -206,7 +268,11 @@ margin above the knee is what absorbs that without re-opening this card.
**Revisit when SPIKE-04 lands, not before.**
The **schema floor is the knee itself**, 400 W, so a legally configured install
cannot land in the 445–505 s regime. What the threshold costs: a circulating
cannot land in the 445–505 s regime — ⚠️ and those two figures are from the
uncommitted campaign; the committed sweep measures 10.0 s at 100 W on its own
grid. See the disclosure at the head of this section. The floor is still the
right shape (fewer runs cross a higher threshold, measured 126/840 at 100 W
against 1/840 at 700 W); its DEPTH is not established by anything re-runnable. What the threshold costs: a circulating
loop smaller than 350 W per unit is not reported, which is deliberate — at that
scale the round-trip loss is a few watts.
@@ -289,6 +355,32 @@ SAFETY-25 — so no stale `.pyc` could exist to be read. Byte deltas recorded;
| M17 | `circulation.py` | default `700` → `500` | **+0 B** | **RED** — the CFG-03 pin and the not-a-round-number check |
| M18 | `main.py` | `await self.check_circulating()` removed | −39 B | **RED** — all four notification checks |
| M19 | `main.py` | publish `round(x or 0.0)` instead of `None` | −68 B | **survivor on the first battery — GAP CLOSED**, then RED |
| **M20** | `main.py` | **`pos, neg = opposing(...)` → `([], [])`** — the attribution clause deleted from the alarm | −102 B | **SURVIVED ALL 26 FILES on the shipped battery — GAP CLOSED**, then RED on §6a |
| M21 | `main.py` | `pos` and `neg` exchanged — the alarm names both packs, both the wrong way round | **+0 B** | **RED** — §6a ×3 (the mirrored run, and "only its own sentence") |
| M22 | `main.py` | `opposing(..., min_w=cfg.min_w / 2.0)` → `min_w=0.0` | −25 B | **RED** — §6a's resting-pack check |
**And four in `admin/goodwe`**, graded through `python3 sim/sitesim.py
--circulation` rather than through the injector — deliberately, because
`inject.py` is the file that takes the rig lock, and "run the injector to grade a
mutation" is not a sentence anybody should have to type. Byte deltas over
`plantsim.py` + `sitesim.py` together.
| # | file | mutation | Δ | result |
|---|---|---|---|---|
| S1 | `plantsim.py` | `wedge()` no longer stops the latch reaching the power stage | −20 B | **RED** — the opposing-signs precondition **and** "the watchdog did not trip" |
| S2 | `sitesim.py` | the sensor computed over the **allocation record** instead of register 0x0518 | **+0 B** | **RED** ×3 — this is M10 restated on the rig |
| S3 | `sitesim.py` | the campaign run on `plant=INSTANT` | −51 B | **RED** — the plant is a *term* of the figures; it was a SURVIVOR until that check was added, and finding out why produced §3's 1048/2250/0 W table |
| S4 | `sitesim.py` | the write stagger removed from the legitimate campaign | −19 B | **RED** — "the legitimate campaign produced no opposition at all" |
**S3 is recorded as a survivor because it was one, and it is the most useful
result in this table.** The scenario first shipped with a vacuity check whose
text claimed `plant=INSTANT` would make the sweep measure 0 W. S3 was run and
**passed**: `INSTANT` with a stagger of exactly one control cycle samples the
plant before the second unit has stepped, so it produces opposition anyway — and
a *larger* worst peak (2250 W) than the modelled plant does (1048 W). The claim
in the check's own text was wrong. It was corrected against the measurement, the
plant was pinned as an explicit term, and S3 is red now for a stated reason
rather than by accident. §3's spread table is that measurement.
**M19 is recorded as a survivor because it was one.** The first battery found no
check grading what `publish()` does when a per-unit reading is missing, so
@@ -297,6 +389,30 @@ watches went unnoticed — the sensor's own `None` rule, defeated one layer out.
A check was added and the mutation re-run; it is red now. The run that found it
was purged, so it is a result and not an artefact.
**M20 IS RECORDED AS A SURVIVOR BECAUSE IT WAS ONE, AND IT IS THE MORE
UNCOMFORTABLE OF THE TWO.** The commit message for this card claimed *"18
mutations, all RED"*, and M1–M19 above have no entry for the notification's
attribution clause. Verification on 2026-09-07 ran it and it **survived the
whole 26-file suite**: the alarm still fires, still stops the site, still says
the meter cannot see it — and no longer says *which two packs* or *which way
round*. `circulation.py`'s own docstring calls that sentence the first thing
somebody woken by this alarm needs and *"impossible to reconstruct from the
number alone"*.
It survived for one reason, and it is not reachability: **M18 (the whole call
removed) is red**, so `check_circulating()` is exercised. The three checks on
that notification asserted `METER CANNOT SEE`, `CONTROL HAS BEEN STOPPED` and
`isolator` — and **never an inverter name**. Nothing graded the attribution.
`test_watchdog.py` §6a closes it, and closes it on a fixture that can tell the
three failures apart. It runs a roster of **`["garage", "shed", "cellar"]`** —
operator names, none of them `UNIT_A`, for test_maintenance.py §11's reason:
on a fixture whose names are the module's own constants a check cannot tell the
roster from the constant. It runs the site **twice, mirrored**, so the sentence
is not symmetric and a swap (M21, `+0 B`) is a different red from a deletion.
And it carries a **third pack resting at 5 W**, so the floor that stops a pack
in standby being named as a participant in a 6 kW loop (M22) is graded too.
## 7. What this card does NOT close
* **Inverter B's ramp and lag are placeholders.** Every transient number in §3
@@ -305,7 +421,9 @@ was purged, so it is a result and not an artefact.
margin. **Do not present any figure here as a measurement of inverter B.**
* **A wedged inverter still cannot be stopped by this add-on.** §1 says so; the
notification says so; there is no software fix and this card does not pretend
to one.
to one. It *can* now be produced on demand: `plantsim.Inverter.wedge()`.
* **The depth of the threshold curve is not established by anything committed.**
§3's disclosure. The window is; the knee's magnitude is not.
* **A loop below ~350 W per unit is not reported.** Deliberate, §3.
* **The saturation freeze fires under a wedged unit and leaves a standing grid
error.** Observed on the rig while producing §2's numbers: with B wedged, the
+179
View File
@@ -1574,6 +1574,185 @@ battery power, circulating power, state of charge, maintenance phase and
controller status by MQTT discovery. This is observability only — the controller works fine without a
broker, and MQTT problems can never affect control.
## Before you commission inverter B: `tools/rehearse_roster.py` (ARCH-02)
The day the `inverters:` roster is declared, two things change that are
**one-way on a live box and invisible until after the restart that applies
them**: the store migration renames the keys that hold the SAFETY-02 latch and
the maintenance history, and the entity ids Home Assistant composes change
shape. This tool runs the **real `Controller`** through that transition against
a **copy** of the state file and reports the difference.
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -v <repo>:/w:ro \
goodwe-addon-checks:1 sh -c 'cd /w/goodwe_controller && \
python3 tools/rehearse_roster.py \
--state /w/state-copy.json \
--before /w/live-options.json \
--after /w/roster-options.json \
--live-entities /w/live-entities.txt \
--reverse --round-trip'
Exit `0` clean · `1` at least one finding · `2` could not run. ⚠️ **`2` is not a
pass.**
### The owner steps, because this session could not take them
⚠️ **The rehearsal is only as good as the state it is pointed at, and nothing in
this repository can fetch that.** SSH to the live box is blocked by policy and
that is correct — `/data/state.json` is the file the running add-on is writing.
So three things have to be exported by hand, onto the machine that runs the
check, **before** the roster is declared:
1. **The state file.** From the Supervisor host:
ha addons info local_goodwe_controller # confirm the slug first
docker cp addon_local_goodwe_controller:/data/state.json ./state-copy.json
or take it out of a full add-on backup, which is the same bytes. ⚠️ **Copy
it, never move it, and never write to the original.** The tool opens the path
it is given exactly once and does all of its writing on a scratch copy — but
the safe habit is to hand it a file the add-on has never heard of.
2. **The options blob**, as `--before`. It is not reachable through the Home
Assistant REST API and no entity carries it; export it from the Supervisor,
the same way `tools/predeploy_check.py` needs it. Write the roster you intend
to deploy into a second file and pass it as `--after`.
3. **The entity list**, as `--live-entities` — one entity id per line:
Developer tools -> Template:
{{ states | map(attribute='entity_id') | select('search','goodwe') | join('\n') }}
⚠️ **Omitting it is a REFUSAL (exit 2), not a quieter run.** The orphan check
is graded against the ids the install actually has, and without that file it
is not graded against reality at all — so the tool will not run rather than
hand back a clean bill of health for a half of the transition it did not
check. If you genuinely have to run without it, pass `--no-live-entities`:
the run then carries a standing banner in the report header **and** on the
exit line saying the orphan check was not graded. The asymmetry used to be
the other way round — an unreadable file refused, a missing flag passed with
exit 0 and no disclosure at all — and the owner runs this once, at
commissioning, under time pressure.
The install carries roughly 49 `goodwe*` entities and this add-on announces
eight of them. The tool separates the ids it owns from the ids it does not,
rather than claiming orphans it cannot cause.
### The reference, which is loaded on every run
`--reference` defaults to `tools/rehearsal/announced-reference.txt` and is read
**whether or not** you pass an entity list. It records what the build is
supposed to announce, three columns per row:
<entity_id> <unique_id> <device identifier>
⚠️ **All three, and the second and third are the ones an entity_id-only list
cannot see.** The tool's own before/after diff is composed twice by the same
build, so a change to the composition moves both sides equally and the diff
reports nothing. Worse, two changes orphan an install *without moving a single
composed entity_id*: renaming `mqtt.BASE` (every `unique_id` changes) and
changing `DEVICE["identifiers"]` (HA sees a new device). Home Assistant keys its
entity registry on `unique_id` and its device registry on the identifier, so
either one creates a **new** entity beside the old — and because discovery is
published `retain=True` with no removal payload, the old config stays on the
broker and the old entity stays in the registry, still named by every automation
that referenced it, never updating again. Both were measured coming back
**exit 0** before the reference carried three columns.
`object_id` is deliberately **not** a column: `app/mqtt.py` records that HA
2026.8 does not honour it in a discovery payload, so renaming one is a true
no-op, and `test_rehearse.py` requires that mutation to **survive** — otherwise
"the reference notices any edit to `mqtt.py`" would be indistinguishable from
"the reference notices what HA keys on".
⚠️ **No result in this repository was produced against real state.** Everything
recorded here and in `test_rehearse.py` was measured on the synthetic fixtures
in `tools/rehearsal/`.
### What it confirms on the synthetic fixtures
⚠️ **This is a SECOND INSTRUMENT reaching a conclusion this repository already
holds, not a discovery.** `test_maintenance.py` section 13 — *"RECORDED, NOT
FIXED: SAFETY-02's migration has the same wrong-namespace hole, and it is a
LATCH"* — already states it, already names `namespaced_latched`, and already
carries the sentence *"the guard is defeated by the very key the defect wrote"*.
That file is on `release/1.0` and this card does not touch it. What is new here
is that the finding is now reachable **from a state file the owner supplies**
rather than only from a fixture built in a test, and that it is reached by a
different route: a full `Controller` start diffed against its predecessor,
rather than assertions over a `MemStore`. Two instruments, one answer.
**The finding.** A pre-DEV-02 flat store that declares a multi-inverter roster
comes up with its SAFETY-02 latch **DISARMED**, unless the first roster entry
happens to be named `inverter-a`. `Controller.__init__` calls
`migrate_unit_keys(store, UNIT_A)` with a hardcoded constant, so the latch lands
in `unit/inverter-a/sign_tripped`; a roster of `["garage", "shed"]` reads
`unit/garage/sign_tripped`, which is absent. And `_grade_store_migration()` —
the guard written for exactly this — sees the migration's own misdirected key
satisfy its `namespaced_latched` test and returns without re-arming. The file
says `sign_tripped: true`, the banner is green, and nothing is held at 0 W.
**Both routes reach it, and they are NOT the same defect.** The rehearsal
reports them as separate legs because they need separate fixes:
| leg | how it gets there |
|---|---|
| `SAME-STEP` | the roster is declared in the same step as the upgrade. The migration runs, and is handed `UNIT_A`. |
| `STAGED` | the flat config runs once under 0.7.0+ first. That start stamps `schema_version = 1`, so the roster start's migration takes its `from_version >= SCHEMA_VERSION` early return and **the latch never moves at all**. |
⚠️ **So "hand `migrate_unit_keys()` the roster's own first name" is NOT the
whole fix, and the rehearsal measures that rather than asserting it.**
`test_rehearse.py` part 5b builds exactly that change and reports:
LEG STAGED: DISARMED
LEG SAME-STEP: clean
The staged path never reaches the line that was changed. A fix that stops there
closes the half an operator is least likely to take and leaves the half that
"upgrade first, declare the roster later" produces. The guard side —
`namespaced_latched` counting a namespace that is not on the roster — is what
covers both, and a change there turns every SAFETY-44 check in part 5 red, which
is what a fix should do to a pin that specifies a defect.
The fix belongs to its own card, shown red first. Part 5 pins the current
behaviour as a requirement so it cannot drift green by accident.
### The reverse direction: the retreat is supported, the round trip is not
If commissioning B goes wrong, **backing out to the flat single-inverter config
is supported and does not corrupt state.** Measured on the fixtures:
* the SAFETY-02 latch and the first pack's maintenance history both survive,
because `store.py` rule 2 keeps the flat spellings **live** rather than merely
present — `maintenance.UnitHistory.mirror_flat` writes them on every change,
so a single-FSM add-on finds a current history rather than one dated the day
the roster was declared;
* **it orphans eight Home Assistant entities** — the four per-unit rows for each
of the two units. **Measured:** the add-on stops announcing them and stops
publishing their state, so their history gaps and any automation whose
trigger names them stops firing, silently and without an error.
⚠️ **What is NOT measured, and was previously overstated here:** whether
advancing again restores them. An earlier draft of this section said the
orphaning was "not recoverable by advancing again: HA keeps the id it composed
at first discovery" — that reasoning cuts the other way, since keeping the
composed id is precisely what would let a re-advertised entity land back on
it, and discovery is published `retain=True` with no removal payload. Nothing
in this repository has tested a re-advance against a real Home Assistant, so
the claim is withdrawn rather than restated in the other direction. Plan the
retreat around the measured part: **the eight rows stop updating for as long
as you are retreated.**
⚠️ **What is NOT supported is the round trip.** A maintenance cycle that
completes *while the install is rolled back* is **discarded** when it
re-advances: `maint_schema_version` is already stamped, so `migrate_maint_keys()`
takes its early return and the pack reads the namespaced history from before the
retreat. The pack is then recalibrated on a schedule that does not know it
already was. **So: retreat freely, but treat a retreat as ending the current
maintenance bookkeeping — and if a cycle ran during it, correct
`unit/<first>/last_completed` by hand before advancing again.** This is a stated
decision, not a discovery made at the inverter.
## Before you deploy: `tools/predeploy_check.py` (PKG-09)
Two ways a deploy has gone wrong on this system, both of which the Supervisor
+72
View File
@@ -225,6 +225,78 @@ same place twice, the check is decoration.
forever, *including after the call site is fixed*. The check was true and
passed for the wrong reason. Habit **9** below is what falls out of it.
* ⚠️ **ARCH-02's OWN REHEARSAL HARNESS, CAUGHT BY ITS OWN MUTATION BATTERY —
the THIRTEENTH instance, and the third in an INSTRUMENT rather than in a
check.** `tools/rehearse_roster.py` reports orphaned Home Assistant entities
by composing the ids the add-on announces **before** the roster switch and
**after** it, and diffing. Both sides are composed by the same build. So the
two mutations that change the id composition itself — renaming the MQTT device
(`"GoodWe"` -> `"GoodWe Controller"`), renaming one site sensor (`"Grid power"`
-> `"Grid power W"`) — moved **every id on both sides equally**, and the
harness reported *"no orphans"* about a change that orphans the entire live
entity list. It read exactly like a clean transition. The first draft's
M5/M6 came back `exit 0` and `exit 2`, and neither was the failure they name.
The fix is habit 2 with a filename: **the reference has to be data that does
not move with the code.** `--live-entities` takes a list of the ids the
install actually has, `tools/rehearsal/live-entities-recorded.txt` is the
recorded stand-in that keeps it live in CI, and a build that announces an id
the list does not carry is now a `COMPOSITION DRIFT` finding rather than a
silence. ⚠️ **The anchor moved for the same reason**: it had been
`compose(mqtt.DEVICE["name"], p1["name"]) == "sensor.goodwe_p1_sample_age"`,
which any renaming of the device satisfies by moving both operands, and it is
now composed from two recorded **literals**.
Ask of an instrument what habit 4 asks of a check: *where did each side of
this comparison come from* — and if a mutation of the subject moves both
sides, the instrument is decoration.
⚠️ **AND REVIEW FOUND TWO MORE IN THE SAME CARD, ONE OF THEM INSIDE THE FIX
FOR THE OTHER — the FOURTEENTH and FIFTEENTH.** Both are the pin, not the
tool, and both are the same sentence in a new costume: *a check whose data
cannot reach the failure it names.*
* **Fourteenth — a COUNT cannot tell one red leg from two.** The rehearsal
reports two independent routes to the disarmed latch (`SAME-STEP` and
`STAGED`) and prints every finding **twice per red leg**: once in that
leg's own block and once in the closing summary. The check named *"NOT THE
SAME-STEP PATH ALONE"* was written `out.count("LATCH CAME UP DISARMED")
>= 2`, so **one** red leg always satisfied it. Measured on three builds:
shipped code (both legs red) → 4; `migrate_unit_keys()` handed the roster
name (SAME-STEP fixed) → 2, **passes**; a guard-side fix (both fixed) → 0.
The check reported `ok` on the build whose only remaining failure *is* the
path it says is not the only one. The fix is to parse the legs and require
the finding in **each**, which is habit 2 again: grade the thing, not a
total over the thing.
* **Fifteenth — and it is INSIDE THAT FIX, which is the third time this file
has had to write that sentence.** The first leg-splitter opened on a
`LEG <id>` marker and never closed, so every line after the *last* marker
was attributed to that leg — including the closing summary, which repeats
every finding from every leg. The last leg therefore read red whenever
**any** leg was red, and the new per-leg check was the old count with extra
steps. It was caught by building the one fixture that separates them: a
tree in which `SAME-STEP` is genuinely clean and `STAGED` is not. The tool
now prints `END LEG <id>` and the splitter closes on it.
The rule that falls out, and it is the cheap one: **a per-thing check that
has never seen the things DISAGREE is a total.** Part 5b exists to make
them disagree on every run.
⚠️ **AND ONE THAT HAS ONLY SEEN THEM DISAGREE IN ONE ARRANGEMENT IS A TOTAL
OVER THAT ARRANGEMENT.** Review found this by attacking the fix itself. Part
5b does make the legs disagree, on every run - and was still blind to a build
that merely REORDERS them, because it had only ever seen the clean leg
printed LAST. Three edits, two of them ordinary refactors (reorder the report
legs, reword a summary line), produced `all checks passed` from a splitter
that closed ZERO legs.
A fixture cannot enumerate the arrangements, so the STRUCTURE is asserted
instead: a leg may not open while one is open, and every leg that opens must
be explicitly closed. That fires on the FIRST edit of all three attacks,
whatever the order or the wording. **When a fixture cannot enumerate the
arrangements, assert the invariant they share.**
* **CFG-04's `tools/supervisor_shape_probe.py` §B** — and this one is the rule
reaching outside the suite. The probe is the committed evidence that
`config.yaml`'s `inverters: []` default is load-bearing, and four documents
+159 -17
View File
@@ -247,7 +247,40 @@ class Controller:
hold_s=float(opts.get("delivery_hold_s",
DELIVERY_OPTION_DEFAULTS["delivery_hold_s"])),
)
self.store_migration = migrate_unit_keys(store, UNIT_A)
# SAFETY-44. THE NAMES THE MACHINES READ, HANDED TO THE MIGRATION THAT
# WRITES FOR THEM. This line used to read `migrate_unit_keys(store,
# UNIT_A)`, and the constant was wrong in exactly the way the
# `maint_migration` call below says the SAME constant would be wrong -
# ⚠️ this comment used to cite "line 315" and "two hundred lines
# below", and both were wrong. The correction I wrote for them was
# wrong too: it said "~54 lines down", which was review's distance to
# the WRONG line, carried to a new referent without re-measuring -
# inside the sentence apologising for a wrong number. There is no
# distance here now, because a distance is a coordinate and the
# sentence above already names the thing. On a declared roster the flat
# latch landed under
# `unit/inverter-a/`, which is on no roster, was read back under the
# name it was handed, and stamped the marker. `roster_names()` is the
# naming rule itself and returns `[UNIT_A]` on the flat shape, so the
# upgrade path every install runs today is byte-identical.
#
# ⚠️ AND `misdirected` IS UNREACHABLE FROM **THIS** CALL SITE, WHICH THE
# COMMIT MESSAGE ORIGINALLY CLAIMED AS A BENEFIT. `unit` is
# `roster_names(opts)[0]` and `build_roster()` names its entries from
# the same `roster_names()` list, so `unit` is a member of `roster` by
# construction and the refusal can never fire here. Review measured it:
# dropping the `roster` argument from the `_verify_moves()` call inside
# `migrate_unit_keys()` survives 28/28.
#
# THE ARGUMENT STAYS ANYWAY, and the reason is the one `_verify_moves()`
# already gives for keeping its own `roster=None` branch: it is what a
# NEW caller gets, and the grading it enables is measured at the
# function's own level in test_maintenance.py rather than through here.
# What is NOT true is that this call site buys the refusal, so that
# claim is gone rather than restated.
_roster_names = roster_names(opts)
self.store_migration = migrate_unit_keys(
store, _roster_names[0], _roster_names)
self.inverters = build_roster(opts, store, sign_cfg, delivery_cfg)
# ⚠️ CFG-04. SAY WHICH SHAPE IS IN FORCE, BECAUSE THE OTHER ONE'S KEYS
# ARE STILL SITTING IN THE UI. A declared `inverters:` roster reads none
@@ -751,23 +784,132 @@ class Controller:
legacy_tripped = bool(data.get("sign_tripped", False))
if not legacy_tripped or not self.inverters:
return
# ⚠️ THE QUESTION IS "IS ANY NAMESPACED LATCH SET", AND IT IS ASKED OF
# THE STORE RATHER THAN OF THE ROSTER. At a roster of two a trip on B
# leaves A untripped, so re-arming A off a flat key that B's own trip
# mirrored would latch a healthy inverter on every restart - the
# flat-key defect this card removed, coming back through its own guard.
# And the STORE is the right place to ask: it holds every
# `unit/*/sign_tripped` this install has ever written, including one
# belonging to a unit that is not in TODAY's roster (a roster is
# configuration and it changes), whereas the roster only knows what is
# configured right now. The flat key carries new information only when
# nothing namespaced is latched at all.
namespaced_latched = any(
bool(v) for k, v in data.items()
if k.startswith("unit/") and k.endswith("/sign_tripped"))
if namespaced_latched or any(inv.sign_wd.tripped
for inv in self.inverters):
# ⚠️ THE QUESTION IS "IS ANY NAMESPACED LATCH SET **THAT A MACHINE
# READS**", AND THE LAST CLAUSE IS SAFETY-44's SECOND HALF. At a roster
# of two a trip on B leaves A untripped, so re-arming A off a flat key
# that B's own trip mirrored would latch a healthy inverter on every
# restart - the flat-key defect DEV-02 removed, coming back through its
# own guard. That much stands, and it is why the question is asked of
# the STORE's keys rather than of the roster's objects.
#
# ⚠️ BUT IT USED TO ACCEPT **ANY** `unit/*/sign_tripped`, and the
# paragraph here defended that: the store "holds every one this install
# has ever written, including one belonging to a unit that is not in
# TODAY's roster ... whereas the roster only knows what is configured
# right now". THE PREMISE IS TRUE AND IT ARGUES THE WRONG WAY. A latch
# under a name no unit answers to is not protection an operator has - it
# is a key nothing reads, which is the definition of the state this
# method exists to repair. Counting it as protection made the guard
# return early on precisely the install that needed it, and that is not
# hypothetical: it is where the migration above USED to put the latch
# on every declared roster, so the guard was blinded by its own upgrade.
#
# THE MIGRATION FIX DOES NOT REACH THOSE INSTALLS. A store stamped by
# the shipped version already has the marker, so `migrate_unit_keys()`
# returns at the version check and never runs again. This guard is the
# ONLY repair path for a site that upgraded before this card, and it
# re-arms onto `inverters[0]` - which lands the latch back under a name
# the roster reads.
# ⚠️ AND "OFF THE ROSTER" IS NOT ENOUGH ON ITS OWN - IT HAS TO BE THE
# MIGRATION'S OWN FINGERPRINT, WHICH IS `UNIT_A`. The first draft of
# this treated EVERY off-roster latch as litter, and test_inverter.py's
# DEV-02 restart case caught it: there the store carries
# `unit/inverter-b/sign_tripped = True` from B's own trip while the
# roster being graded holds only A, and the blunt rule re-armed a
# HEALTHY A off the flat key that B's trip had mirrored - DEV-02's
# defect rebuilt inside its own guard, which is exactly what the
# paragraph above was right to be afraid of.
#
# `UNIT_A` is the discriminator because it is the ONLY name the broken
# migration ever wrote. `roster_names()` falls back to a POSITIONAL
# `inverter-N`, so `inverter-a` is not a name this code hands to anyone
# else. A latch stranded under any OTHER name is a real unit that is
# out of today's config - a roster is configuration and it changes -
# and it still counts as protection.
#
# ⚠️ THE RESIDUAL, STATED RATHER THAN LEFT TO BE FOUND: a site whose
# own first unit is NAMED `inverter-a`, latched, and then taken out of
# the roster gets re-armed on `inverters[0]`. That is a healthy unit
# held at 0 W until an operator clears it, which is the conservative
# direction; SAFETY-44's is a site DRIVING on a state file that says it
# is latched.
#
# ⚠️ AND THE SECOND HALF OF THAT RESIDUAL IS THE **SENTENCE**, NOT THE
# BOOLEAN. The reason re-armed from is the FLAT mirror, which carries
# whatever tripped LAST - so on a multi-unit site this path can print
# one inverter's evidence under another inverter's name. The latch is
# right and the explanation may not be. It is disclosed here because the
# banner is what an operator acts on, and `test_maintenance.py`'s check
# that the reason is not the removed unit's is guaranteed by that
# fixture's choice of flat string, NOT by this code.
# ⚠️ AND THE THREE CASES ARE THREE VARIABLES, NOT ONE SHARED FLAG.
# THIS IS WHERE THE FIRST VERSION OF THIS FIX WAS DEFEATED, BY ONE KEY.
# It wrote `namespaced_latched = True` for BOTH "on the roster" and "off
# the roster but not UNIT_A", so a single latched key belonging to a
# unit the operator had removed from `inverters:` vetoed the repair of
# the misdirected `inverter-a` latch sitting beside it. Measured on the
# shipped build, roster ['garage', 'shed'], store stamped so the
# migration cannot reach it:
#
# sign_tripped + unit/inverter-a/sign_tripped
# -> [('garage', True), ('shed', False)] repaired
# ...AND unit/old-shed/sign_tripped
# -> [('garage', False), ('shed', False)] ⚠️ DRIVING
#
# A site DRIVING on a state file that reads `sign_tripped: true`, which
# is the sentence this whole card exists to make false - and
# `tools/rehearse_roster.py` said so, on the unmodified tree, exit 1.
#
# ⚠️ IT IS ALSO INSTANCE #17 OF HABIT 4, IN THE EXTENSION MUTATION.md
# ADDED TWO DAYS AGO. The two fixtures written for this card each hold
# ONE arrangement - `_staged_store` has only `inverter-a`, `_other_store`
# has only `inverter-b` - and the flag is written twice with opposite
# intents only when BOTH are present. A per-key check whose keys have
# never disagreed WITHIN ONE STORE is a check over whichever key came
# last. The fixture below now holds both.
#
# THE THREE CASES, AND WHY THE ANSWER DIFFERS FOR EACH:
# on the roster a machine reads it: real protection, stop.
# off-roster, `UNIT_A` the broken migration's fingerprint - this
# IS the flat latch, misdirected. Repair.
# off-roster, other name a real unit out of today's config. Its
# latch is unread too, but it is NOT the flat
# key's misdirection, and re-arming a healthy
# sibling off it is DEV-02's defect. Stop -
# UNLESS a stranded `UNIT_A` latch is also
# present, because then the flat key IS
# accounted for and the repair is the point.
on_roster = {inv.name for inv in self.inverters}
stranded, on_roster_latched, other_off_roster = [], False, False
for k, v in data.items():
if not (k.startswith("unit/") and k.endswith("/sign_tripped")):
continue
if not bool(v):
continue
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1 -
# split through the writer's own spelling rather than re-deriving.
unit = k.split("/")[1]
if unit in on_roster:
on_roster_latched = True
elif unit == UNIT_A:
stranded.append(k)
else:
other_off_roster = True
if on_roster_latched or any(inv.sign_wd.tripped
for inv in self.inverters):
return
if other_off_roster and not stranded:
# A removed unit's own latch, and no misdirected one. The flat key
# is that unit's mirror, so it carries no new information and
# re-arming `inverters[0]` from it would latch a healthy pack.
return
if stranded:
_LOG.error(
"SAFETY-44: this store carries a LATCHED sign-disagreement "
"failsafe under %s, and that unit is NOT on today's roster "
"%s - so no machine reads it and the site came up DRIVING on "
"a state file that says it is latched. Re-arming below.",
", ".join(sorted(stranded)), sorted(on_roster))
first = self.inverters[0]
# The one state this method exists for.
reason = str(data.get("sign_reason", "") or "")
+40 -8
View File
@@ -253,7 +253,7 @@ class MigrationReport:
return out
def migrate_unit_keys(store, unit: str) -> MigrationReport:
def migrate_unit_keys(store, unit: str, roster=None) -> MigrationReport:
"""Move the pre-DEV-02 flat per-inverter keys into `unit`'s namespace.
`unit` is the FIRST inverter in the roster, which on every install that can
@@ -261,6 +261,24 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
version that knew about more than one inverter is already at
SCHEMA_VERSION and is not touched.
⚠️ `roster` IS THE NAMES THE MACHINES READ, AND IT IS SAFETY-44. This
function shipped WITHOUT it, taking a hardcoded `inverter.UNIT_A` at its one
call site - which is, exactly, the MAINT-05 defect `_verify_moves()` spends
a paragraph describing, still live in the sibling that paragraph was written
beside. On a store that ran pre-DEV-02 flat and declares
`inverters: [garage, shed]`, the latch moved to `unit/inverter-a/
sign_tripped`, was read back under that same name, was SATISFIED, and
stamped the marker - so the next start skipped the migration too and NO
inverter came up latched on an install whose state file says `sign_tripped:
true`. A disarmed SAFETY-02 guard, every symptom of a clean migration.
`roster_names(opts)` answers this BEFORE any `Inverter` exists - it is
defined to, for `MqttPublisher`, which is constructed before the roster for
the same reason - so the "there is no roster yet" that kept this argument
out was never true. On the flat shape it returns `[UNIT_A]`, so the upgrade
every install runs today passes `unit == roster[0] == UNIT_A` and is
byte-identical.
THE THREE RULES, each one a case that would otherwise be a wrong answer:
1. IT NEVER OVERWRITES AN EXISTING NAMESPACED VALUE. A store that has both
@@ -302,7 +320,7 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
rep.ran = True
_move_flat_keys(store, unit, LEGACY_UNIT_KEYS, rep)
_verify_moves(store, rep)
_verify_moves(store, rep, roster)
if rep.ok:
store.set(SCHEMA_KEY, SCHEMA_VERSION)
@@ -383,12 +401,26 @@ def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
itself. `test_maintenance.py` section 9c calls this twice with the SAME
wrong `unit` over one store, which is the shape nothing in the suite had.
`roster=None` means "not graded", and the one caller that passes it is
`migrate_unit_keys()`, which runs BEFORE `build_roster()` for the reason its
own docstring gives - the `Inverter` objects restore their latch in their
constructors, so the keys have to be in place before there is a roster to
check against. That gap is real and is recorded as a running check rather
than left to a comment: see test_maintenance.py section 13.
`roster=None` still means "not graded", and SAFETY-44 is what it cost. This
paragraph used to end here, saying that the one caller passing it was
`migrate_unit_keys()`, "which runs BEFORE `build_roster()` ... so the keys
have to be in place before there is a roster to check against", and calling
that gap real. THE FIRST HALF IS TRUE AND THE CONCLUSION IS NOT. The keys do
have to be written before the `Inverter`s restore from them - but the NAMES
do not come from the `Inverter`s. `roster_names(opts)` reads them straight
off the options, before anything is constructed, which is the whole reason
it exists (`MqttPublisher` is built before the roster too). So the argument
that could have graded that write was available at the call site the entire
time, and the sibling function three screens down had already been fixed by
passing it.
⚠️ SO NO CALLER PASSES `None` TODAY, and this branch is kept for one reason:
it is what a NEW caller gets by DEFAULT. A future migration that forgets the
roster gets the ungraded read-back and stamps its marker over a namespace no
machine reads - silently, exactly as this one did. That is the shape of both
defects, and the gap is recorded as a running check rather than left to a
comment: test_maintenance.py section 13, and test_rehearse.py part 5, which
drives it end-to-end through the harness an operator actually runs.
"""
for target, value in rep.moved.items():
got = getattr(store, "data", {}).get(target, "<<missing>>")
+196 -50
View File
@@ -21,6 +21,7 @@ Read MUTATION.md before you report a survivor.
import asyncio
import dataclasses
import logging
import re
import sys
from datetime import datetime, timedelta, timezone
@@ -34,7 +35,8 @@ from app.inverter import UNIT_A
from app.main import Controller
from app.store import (
DEFAULTS, MAINT_SCHEMA_KEY, MAINT_SCHEMA_VERSION, MAINT_UNIT_KEYS,
migrate_maint_keys, unit_key,
SCHEMA_KEY, SCHEMA_VERSION, migrate_maint_keys, migrate_unit_keys,
unit_key,
)
fails = []
@@ -1411,45 +1413,39 @@ check("...while the SAME site with the house importing more than the drain "
# --------------------------------------------------------------------------- #
print()
print("13. RECORDED, NOT FIXED: SAFETY-02's migration has the same "
"wrong-namespace hole, and it is a LATCH")
print("13. FIXED (SAFETY-44): SAFETY-02's migration takes the roster too, and "
"the guard is no longer defeated by the key the defect wrote")
# ⚠️ FOUND WHILE FIXING THIS CARD'S OWN INSTANCE, AND WRITTEN DOWN AS A RUNNING
# CHECK RATHER THAN A COMMENT SO IT CANNOT BE FORGOTTEN OR QUIETLY "FIXED" BY AN
# UNRELATED EDIT. It is the SAME defect one module over, and it costs more.
# ⚠️ THIS SECTION USED TO BE THE SPECIFICATION OF A DEFECT. It was written while
# fixing MAINT-05's own instance, as a RUNNING check rather than a comment, with
# the instruction that "the card that fixes it should expect them to go red and
# should replace them". SAFETY-44 is that card and this is that replacement.
#
# WHAT IT IS. `Controller.__init__` calls `migrate_unit_keys(store, UNIT_A)` -
# a hardcoded constant, where the maintenance migration now passes the roster's
# own first name. It has to run BEFORE `build_roster()`, because each
# `Inverter` restores its sign latch in its constructor, so there is no roster
# to name yet and `_verify_moves()` is passed `roster=None`. On a DECLARED
# roster the pre-DEV-02 flat `sign_tripped: true` therefore lands under
# `unit/inverter-a/sign_tripped`, which no unit on this site reads.
# WHAT IT WAS. `Controller.__init__` called `migrate_unit_keys(store, UNIT_A)` -
# a hardcoded constant, where the maintenance migration already passed the
# roster's own first name. On a DECLARED roster the pre-DEV-02 flat
# `sign_tripped: true` landed under `unit/inverter-a/sign_tripped`, which no
# unit on the site reads, and `_verify_moves()` read it back under the name it
# had been handed and was satisfied.
#
# WHY THE EXISTING GUARD DOES NOT CATCH IT, WHICH IS THE PART WORTH PINNING.
# `_grade_store_migration()` re-arms the latch when the flat key says tripped
# and NOTHING NAMESPACED IS LATCHED. The misdirected write IS namespaced and IS
# latched, so `namespaced_latched` is True, the guard returns, and no inverter
# on the roster comes up tripped. The guard is defeated by the very key the
# defect wrote.
# AND THE GUARD DID NOT CATCH IT, WHICH WAS THE PART WORTH PINNING.
# `_grade_store_migration()` re-arms when the flat key says tripped and NOTHING
# NAMESPACED IS LATCHED - and the misdirected write IS namespaced and IS
# latched. The guard was defeated by the very key the defect wrote.
#
# WHY IT IS NOT FIXED HERE. The remedy is to name the migration's target from
# the config before any `Inverter` exists, which means factoring
# `build_roster()`'s naming rule out of `build_roster()` so there is still only
# ONE spelling of it - inverter.py and SAFETY-02's latch semantics, with its own
# mutation battery over test_inverter.py and test_watchdog.py. MAINT-05 moves
# the maintenance history and nothing about the sign latch.
#
# ⚠️ AND IT IS A DISARMED SAFETY GUARD, NOT LOST HISTORY. Read the checks below
# as the SPECIFICATION OF A DEFECT: every one of them is asserting the WRONG
# behaviour, on purpose, and the card that fixes it should expect them to go
# red and should replace them.
# ⚠️ THE REASON IT WAS LEFT - "there is no roster to name yet, because each
# `Inverter` restores its latch in its constructor" - WAS THE HALF-TRUTH THAT
# KEPT IT ALIVE. The keys must be written before the `Inverter`s read them; the
# NAMES do not come from the `Inverter`s. `roster_names(opts)` reads them off
# the options before anything is constructed, which is what it already existed
# to do for `MqttPublisher`. The factoring this section said the fix would need
# had been done a card earlier.
#
# ⚠️ NO `schema_version` IN THIS STORE, AND THAT IS THE PRE-DEV-02 SHAPE
# ITSELF: an install that has never run a version with namespaced keys, which is
# what makes `migrate_unit_keys()` run at all.
_lat_store = MemStore({"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: measured pre-DEV-02"})
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured"})
_lat, _ = controller(NAMED_OPTS, store=_lat_store)
print(f" pre-DEV-02 flat latch + declared roster {_C2_NAMES} -> "
f"{_lat.store_migration.explain()}")
@@ -1457,29 +1453,179 @@ print(f" latch keys in the store: "
f"{sorted(k for k in _lat_store.data if k.endswith('sign_tripped'))}")
print(f" roster came up tripped: "
f"{[(i.name, i.sign_wd.tripped) for i in _lat.inverters]}")
check("the flat pre-DEV-02 latch is migrated into `unit/inverter-a/` - a name "
"that is NOT on this roster, so no unit reads it. This is the same "
"wrong-namespace write `migrate_maint_keys()` now refuses, in the "
"migration that runs before there is a roster to grade against",
_lat_store.data.get(unit_key(UNIT_A, "sign_tripped")) is True
and unit_key(_C2_NAMES[0], "sign_tripped") not in _lat_store.data)
check("⚠️ AND NO INVERTER ON THE ROSTER COMES UP LATCHED, on a store whose file "
"still reads `sign_tripped: true`. That is DEV-02's own sentence for the "
"worst case - a disarmed safety guard on an install that believes it is "
"protected",
not any(i.sign_wd.tripped for i in _lat.inverters)
check("⭐ the flat pre-DEV-02 latch is migrated into the ROSTER'S OWN first "
"namespace and NOT into `unit/inverter-a/` - the same wrong-namespace "
"write `migrate_maint_keys()` refuses, now refused in the migration that "
"runs before the `Inverter` objects exist",
_lat_store.data.get(unit_key(_C2_NAMES[0], "sign_tripped")) is True
and unit_key(UNIT_A, "sign_tripped") not in _lat_store.data)
check("⭐ AND AN INVERTER ON THE ROSTER COMES UP LATCHED, which is the whole "
"card: a store whose file reads `sign_tripped: true` no longer produces "
"a site that drives",
_lat.inverters[0].sign_wd.tripped
and _lat_store.data["sign_tripped"] is True)
check("...and `_grade_store_migration()` is DEFEATED BY THE KEY THE DEFECT "
"WROTE, which is why this needs a card rather than a tweak: its "
"'is anything namespaced latched' test is satisfied by the misdirected "
"value itself, so it returns instead of re-arming",
any(k.startswith("unit/") and k.endswith("/sign_tripped") and v
for k, v in _lat_store.data.items())
and not any(i.sign_wd.tripped for i in _lat.inverters))
check("...and the latch that came back carries the REASON, not just the "
"boolean - an operator meeting a FAILSAFE banner with no sentence under "
"it has been told the site stopped and not why",
"SIGN_DISAGREEMENT" in _lat.inverters[0].sign_wd.reason)
check("⚠️ ...and the OTHER units on the roster are NOT latched by it. The flat "
"key is one site-level boolean and re-arming every unit off it would be "
"DEV-02's defect in the opposite direction - a healthy pack parked "
"because its sibling once disagreed",
not any(i.sign_wd.tripped for i in _lat.inverters[1:]))
# ⚠️ AND THE OTHER PATH, WHICH THE MIGRATION FIX ALONE DOES NOT REACH: a store
# ALREADY STAMPED by the shipped version. `migrate_unit_keys()` returns at its
# `from_version >= SCHEMA_VERSION` check and never moves anything, so the
# stranded `unit/inverter-a/sign_tripped` just sits there. This is the STAGED
# shape - "upgrade now, declare the roster later" - and it is the one an
# operator actually produces. Only the GUARD repairs it.
_staged_store = MemStore({
"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
unit_key(UNIT_A, "sign_tripped"): True,
unit_key(UNIT_A, "sign_reason"): "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
SCHEMA_KEY: SCHEMA_VERSION})
_staged, _ = controller(NAMED_OPTS, store=_staged_store)
print(f" already-stamped store + declared roster -> ran="
f"{_staged.store_migration.ran}, came up tripped: "
f"{[(i.name, i.sign_wd.tripped) for i in _staged.inverters]}")
check("⚠️ PRECONDITION: the migration really does NOT run on this store - the "
"marker is already there - so anything below is the guard's doing and "
"not the migration's",
not _staged.store_migration.ran)
check("⭐ and the roster's first unit STILL comes up latched: the guard no "
"longer counts a latch stranded under `UNIT_A`, which is not on this "
"roster, as protection an operator has",
_staged.inverters[0].sign_wd.tripped)
check("...and the re-armed latch is written where a machine reads it, so the "
"NEXT start finds it without needing the guard again",
_staged_store.data.get(unit_key(_C2_NAMES[0], "sign_tripped")) is True)
# ⚠️ THE NEGATIVE CONTROL FOR THAT NARROWING, because "off the roster" on its
# own was too blunt and test_inverter.py's DEV-02 restart case caught it: a
# latch stranded under a name that is NOT `UNIT_A` is a real unit out of
# today's config, and it MUST still count as protection.
_other_store = MemStore({
"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: B disagreed, mirrored flat",
unit_key("inverter-b", "sign_tripped"): True,
unit_key("inverter-b", "sign_reason"): "SIGN_DISAGREEMENT: B disagreed",
SCHEMA_KEY: SCHEMA_VERSION})
_other, _ = controller(NAMED_OPTS, store=_other_store)
print(f" stranded latch under `inverter-b` -> came up tripped: "
f"{[(i.name, i.sign_wd.tripped) for i in _other.inverters]}")
check("⚠️ a latch stranded under a name that is not `UNIT_A` is NOT re-armed "
"onto the roster: it belongs to a unit the operator has taken out of "
"the config, the flat key is its own mirror, and re-arming a healthy "
"sibling off it is the defect DEV-02 removed",
not any(i.sign_wd.tripped for i in _other.inverters))
# ⭐ AND THE ARRANGEMENT NEITHER FIXTURE ABOVE HOLDS: **BOTH** KEYS AT ONCE.
# ⚠️ THIS IS INSTANCE #17 OF HABIT 4 AND IT WAS FOUND IN THIS CARD'S OWN FIX.
# The first version of the guard classified each key and then wrote ONE shared
# `namespaced_latched` flag for both "on the roster" and "off-roster but not
# UNIT_A". `_staged_store` above holds only an `inverter-a` latch and
# `_other_store` below holds only an `inverter-b` one, so the flag was never
# written twice with opposite intents in a single store - and a per-key check
# whose keys have never disagreed WITHIN ONE STORE is a check over whichever
# key happened to come last. Measured on that build, one added key:
#
# sign_tripped + unit/inverter-a/sign_tripped -> garage latched, repaired
# ...AND unit/old-shed/sign_tripped -> ⚠️ NOTHING latched
#
# A site DRIVING on a state file that reads `sign_tripped: true` - the sentence
# this card exists to make false - reached by an operator taking one unit out of
# `inverters:` after it had tripped. "A roster is configuration and it changes"
# is this module's own phrase, and the second inverter goes in this month.
_both_store = MemStore({
"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
unit_key(UNIT_A, "sign_tripped"): True,
unit_key(UNIT_A, "sign_reason"): "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
unit_key("old-shed", "sign_tripped"): True,
unit_key("old-shed", "sign_reason"): "SIGN_DISAGREEMENT: old-shed, since removed",
SCHEMA_KEY: SCHEMA_VERSION})
_both, _ = controller(NAMED_OPTS, store=_both_store)
print(f" stranded `inverter-a` AND an unrelated off-roster `old-shed` -> "
f"{[(i.name, i.sign_wd.tripped) for i in _both.inverters]}")
check("⭐ A STRANDED `inverter-a` LATCH IS STILL REPAIRED WHEN AN UNRELATED "
"OFF-ROSTER LATCH SITS BESIDE IT. One shared flag let `old-shed` veto the "
"repair, and the site came up driving on a file that says it is latched",
_both.inverters[0].sign_wd.tripped)
check("...and the repair still lands on the roster's first unit, not on the "
"removed one",
_both_store.data.get(unit_key(_C2_NAMES[0], "sign_tripped")) is True)
check("⚠️ ...and the reason carried across is the FLAT one - the misdirected "
"latch's own - and not `old-shed`'s, which belongs to a different "
"physical inverter",
"old-shed" not in _both.inverters[0].sign_wd.reason)
# ⚠️ AND THE OPERATOR MESSAGE IS ASSERTED, because review deleted it whole
# (`if stranded:` -> `if False:`) and 28/28 stayed green. `stranded` has no
# other consumer, so the list existed only to print a line nothing checked -
# and the line is the entire operator-facing half of this repair: it names the
# key that was stranded and the roster it was measured against. A repair an
# operator cannot see is a repair they cannot confirm.
_log_store = MemStore({
"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
unit_key(UNIT_A, "sign_tripped"): True,
unit_key(UNIT_A, "sign_reason"): "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
SCHEMA_KEY: SCHEMA_VERSION})
_records = []
_h = logging.Handler()
_h.emit = lambda r: _records.append(r.getMessage())
logging.getLogger("goodwe").addHandler(_h)
try:
_logged, _ = controller(NAMED_OPTS, store=_log_store)
finally:
logging.getLogger("goodwe").removeHandler(_h)
_s44_lines = [m for m in _records if "SAFETY-44" in m]
print(f" SAFETY-44 operator lines logged: {len(_s44_lines)}")
check("⭐ the repair SAYS SO, at ERROR, naming SAFETY-44 - deleting this line "
"left the whole suite green, and it is how an operator learns their "
"latch was re-armed rather than never lost",
len(_s44_lines) == 1)
check("...and it names the STRANDED KEY, so the operator can find it in the "
"state file rather than being told something happened",
any(unit_key(UNIT_A, "sign_tripped") in m for m in _s44_lines))
check("...and the ROSTER it was measured against, which is the half that says "
"WHY no machine read it",
any("garage" in m and "shed" in m for m in _s44_lines))
# ⭐ AND `misdirected` IS MEASURED AT THE FUNCTION'S OWN LEVEL, BECAUSE IT IS
# UNREACHABLE FROM THE CALL SITE. `Controller.__init__` passes
# `roster_names(opts)[0]`, which `build_roster()` also names its first entry
# from, so `unit` is a member of `roster` by construction and the refusal can
# never fire there. Review measured that: dropping the `roster` argument from
# `_verify_moves()` inside `migrate_unit_keys()` survives 28/28. The argument is
# kept for the reason `_verify_moves()` gives for its own `roster=None` branch -
# it is what a NEW caller gets by default - and a guard nothing exercises is a
# guard nobody knows works, so it is exercised HERE rather than claimed.
_mis_store = MemStore({"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured"})
_mis = migrate_unit_keys(_mis_store, UNIT_A, ["garage", "shed"])
print(f" migrate_unit_keys(store, 'inverter-a', ['garage','shed']) -> "
f"ok={_mis.ok} misdirected={sorted(_mis.misdirected)}")
check("⭐ a WRONG `unit` - one that is not on the roster it was handed - comes "
"back `misdirected`, which is the refusal `migrate_maint_keys()` already "
"had and this one did not",
bool(_mis.misdirected) and not _mis.ok)
check("...and the schema marker is NOT stamped, so the migration runs again on "
"the next start rather than being recorded as finished - the one thing "
"store.py's header says must never happen",
_mis_store.data.get(SCHEMA_KEY) != SCHEMA_VERSION)
check("⚠️ ...while the SAME call with a unit that IS on the roster stamps the "
"marker - so the refusal is caused by the name and not by the fixture",
migrate_unit_keys(
MemStore({"sign_tripped": True, "sign_reason": "x"}),
"garage", ["garage", "shed"]).ok)
# The counterfactual, and it is the accepted baseline habit 7 asks for: the
# same store, the same migration, one thing different - the roster shape.
_flat_lat_store = MemStore({"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: measured pre-DEV-02"})
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured"})
_flat_lat, _ = controller(FLAT_OPTS, store=_flat_lat_store)
print(f" the same store on the FLAT config -> roster "
f"{[(i.name, i.sign_wd.tripped) for i in _flat_lat.inverters]}")
File diff suppressed because it is too large Load Diff
+185 -10
View File
@@ -722,18 +722,41 @@ print(f" e.g. {_sustained.reason!r}")
print()
print(" ⚠️ AND NOT ON THE TRANSIENT, WHICH IS THE FALSE-ALARM CASE")
# ⚠️ THE SHAPES BELOW ARE THE RIG'S, NOT INVENTED. Measured on TEST-19 over 924
# closed-loop runs in which every unit executed the share it was given: the
# worst continuous opposition above 700 W is 5.0 s - ONE control cycle - at a
# peak of 1700 W. That is the serialised write path: on a reversing cycle B's
# new number lands up to a whole cycle after A's, both slew through zero at
# 800 W/s behind a 10 s lag, and between the two crossings they genuinely
# oppose. A guard that trips on that is a false-alarm generator, and this
# project's doctrine on crying wolf is explicit (SAFETY-15, SAFETY-19).
# THE SHAPE BELOW IS THE RIG'S: the serialised write path means that on a
# reversing cycle B's new number lands up to a whole control cycle after A's,
# both slew through zero behind a real ramp and a real lag, and between the two
# crossings they genuinely oppose. A guard that trips on that is a false-alarm
# generator, and this project's doctrine on crying wolf is explicit (SAFETY-15,
# SAFETY-19).
#
# ⚠️ THE MAGNITUDE IS A FIXTURE INPUT, NOT A MEASUREMENT, AND THIS COMMENT USED
# TO SAY OTHERWISE. It read "the worst single transient the rig can produce -
# 1700 W", sourced to a 924-run campaign whose harness IS NOT COMMITTED IN
# EITHER REPOSITORY (DECISIONS/SAFETY-31-circulating-power.md §3 says so in its
# own words). Three sweeps have now been run against that claim and no two agree:
#
# 924-run campaign, harness uncommitted peak 1700 W, 505.0 s at 100 W
# independent reviewer sweep, 2026-09-07 peak 2750 W, 15.0 s above 700 W
# goodwe `two_master_circulation`, 840 runs peak 1048 W, 5.0 s above 700 W
#
# ⚠️ THAT IS NOT EVIDENCE THAT ANY OF THEM IS WRONG. The three swept different
# grids, and the last one measured WHY the spread is so wide: on its own grid the
# worst legitimate peak is 1048 W on the modelled plant, 2250 W on `INSTANT`, and
# 0 W on `INSTANT` with no write stagger reaching a whole cycle. The plant model
# and the stagger set are TERMS of the number, and a worst case quoted without
# them is not a measurement. It is evidence that the claim cannot be adjudicated
# while the instrument is missing.
#
# So what this check now says is what it can support: 1700 W is LARGER than
# anything any of the three sweeps produced legitimately, and one control cycle
# of it does not trip. The committed, re-runnable version of the claim is
# `admin/goodwe`'s `sim/inject.py two_master_circulation`, which runs on every
# pull request there and requires ZERO of ~840 non-faulty runs to trip.
_one_cycle = [0.0] * 5 + [1700.0] + [0.0] * 30
_t1, _ = circ_drive(_one_cycle)
check("the worst single transient the rig can produce - 1700 W for ONE control "
"cycle - does not trip", not _t1.tripped)
check("a single transient of 1700 W - larger than the worst peak any committed "
"sweep of the legitimate family has produced - lasting ONE control cycle "
"does not trip", not _t1.tripped)
_repeated = ([0.0] * 3 + [1700.0]) * 20
_t2, _ = circ_drive(_repeated)
check("...and neither does it REPEATED every fourth cycle for 400 s, which is "
@@ -1091,6 +1114,158 @@ check("...and it does not raise the same card on every cycle - a per-PROCESS "
"this' a different question from 'is this site tripped'",
len(_notes2) == 1)
print()
print("6a. AC 4: THE ALARM NAMES WHICH PACKS, AND WHICH WAY ROUND")
# ⚠️ THIS BLOCK EXISTS BECAUSE A MUTATION SURVIVED ALL 26 FILES. Replacing
#
# pos, neg = opposing([inv.batt for inv in self.inverters],
# min_w=self.circ_wd.cfg.min_w / 2.0)
#
# in `check_circulating()` with `pos, neg = ([], [])` (-102 B) silently deletes
# the "Right now X is discharging while Y charges" clause from the notification,
# and the whole suite stayed GREEN. The block above asserts three tokens -
# METER CANNOT SEE, CONTROL HAS BEEN STOPPED, isolator - and NEVER an inverter
# name, so the one sentence `circulation.py` calls "the first thing somebody
# woken by this alarm needs" and "impossible to reconstruct from the number
# alone" was graded by nothing at all.
#
# ⚠️ IT IS NOT DEAD CODE, WHICH IS THE PART THAT MAKES IT A CHECK GAP RATHER
# THAN A REACHABILITY ONE. Ledger M18 - the `await self.check_circulating()`
# call removed - goes red on four checks above, so this function runs. What
# nothing asserted was the ATTRIBUTION.
#
# ⚠️ AND THE ROSTER IS ["garage", "shed", "cellar"], WHICH IS THE OTHER HALF OF
# THE FIX. Every fixture above runs on `inverter-a`/`inverter-b`;
# test_maintenance.py section 11 records what that costs - on a fixture whose
# names are the module's own constants, a check cannot tell the roster from the
# constant, and `UNIT_A` is not even what a nameless entry falls back to. Here
# the names are an operator's, the two participants pull OPPOSITE ways so the
# sentence is not symmetric, and a third pack rests at 5 W. That makes three
# DIFFERENT mutations - the clause dropped, the two directions swapped, and the
# `min_w` floor dropped - into three DIFFERENT red checks. A fixture naming both
# units the same, or one in which the direction did not matter, would see the
# first and neither of the others.
_CIRC_ROSTER = [
dict(name="garage", setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
batt_entity="sensor.batt_a", max_w=5000),
# ⚠️ B_RATING_HYPOTHESIS, and part 4's note above says why it is not 4600:
# inverter B does not physically exist and a datasheet is not a measurement.
dict(name="shed", setpoint_entity="number.sp_b", soc_entity="sensor.soc_b",
batt_entity="sensor.batt_b", max_w=B_RATING_HYPOTHESIS),
# THE BYSTANDER. A third pack resting at standby draw, on the roster, read
# every cycle, and NOT a participant in anybody's loop.
dict(name="cellar", setpoint_entity="number.sp_c", soc_entity="sensor.soc_c",
batt_entity="sensor.batt_c", max_w=B_RATING_HYPOTHESIS),
]
_CIRC_NAMES = [e["name"] for e in _CIRC_ROSTER]
_REST_W = 5.0
class _NamedCircHass(_Hass):
"""Three named packs; the meter still reads exactly zero.
Same house as `_CircHass` - grid = load - sum(B) with two units cancelling -
with one extra pack idling at standby draw so that "who is named" and "who
is merely on the roster" are different questions.
"""
def __init__(self, a_w, b_w, c_w=_REST_W):
super().__init__()
self.vals = {"sensor.batt_a": a_w, "sensor.batt_b": b_w,
"sensor.batt_c": c_w}
async def number(self, entity, invert=False):
return {"sensor.grid": 0.0, "sensor.soc_a": 50.0,
"sensor.soc_b": 50.0, "sensor.soc_c": 50.0,
**self.vals}.get(entity)
def _named_trio(hass, store, *, hold_s=1, min_w=700):
opts = {"meter_entity": "sensor.grid", "meter_source": "off",
"max_w": 5000, "gain": 0.6, "auto_start": True,
"circulation_w": min_w, "circulation_hold_s": hold_s,
"inverters": _CIRC_ROSTER}
c = Controller(opts, hass, store, _P(), stop=asyncio.Event())
c.auto = True
for inv in c.inverters:
inv.dev_min, inv.dev_max = -5000.0, 5000.0
return c
async def _circ_named(a_w, b_w):
hass = _NamedCircHass(a_w, b_w)
c = _named_trio(hass, _MemStore())
for _ in range(14):
await c.read_inputs()
await c.cycle()
await asyncio.sleep(0.25)
notes = [x for x in hass.calls
if x[0] == "persistent_notification"
and x[2].get("notification_id") == "goodwe_circulating_power"]
return c, notes
_cg, _notes_g = asyncio.run(_circ_named(+3000.0, -3000.0))
_cs, _notes_s = asyncio.run(_circ_named(-3000.0, +3000.0))
_msg_g = _notes_g[0][2]["message"] if _notes_g else ""
_msg_s = _notes_s[0][2]["message"] if _notes_s else ""
print(f" roster : {_cg.units}")
print(f" garage discharging, measured: "
f"{dict(zip(_cg.units, [i.batt for i in _cg.inverters]))}")
print(f" -> {_msg_g.split(chr(10))[2] if _msg_g.count(chr(10)) > 2 else _msg_g!r}")
print(f" shed discharging, measured: "
f"{dict(zip(_cs.units, [i.batt for i in _cs.inverters]))}")
print(f" -> {_msg_s.split(chr(10))[2] if _msg_s.count(chr(10)) > 2 else _msg_s!r}")
# --- THE PRECONDITIONS, AS THEIR OWN CHECKS (MUTATION.md rule 1) ------------
check("PRECONDITION: the roster carries three DISTINCT operator-chosen names, "
f"none of them `UNIT_A` ({UNIT_A!r}) - so every claim below is a claim "
"about the roster and not about a constant that happens to equal it",
_cg.units == tuple(_CIRC_NAMES) and len(set(_CIRC_NAMES)) == 3
and UNIT_A not in _CIRC_NAMES)
check("PRECONDITION: the two runs are MIRROR IMAGES - same house, same meter at "
"0 W, same magnitudes, and the two participants swapped. Without this the "
"direction claim below could be satisfied by a sentence that ignores the "
"measurement entirely",
[i.batt for i in _cg.inverters] == [+3000.0, -3000.0, _REST_W]
and [i.batt for i in _cs.inverters] == [-3000.0, +3000.0, _REST_W]
and _cg.grid == 0.0 and _cs.grid == 0.0)
check("PRECONDITION: both runs actually tripped and actually raised the card, "
"so an absent sentence below is an absent sentence and not an absent alarm",
_cg.circ_wd.tripped and _cs.circ_wd.tripped
and len(_notes_g) == 1 and len(_notes_s) == 1)
# --- AC 4: THE ATTRIBUTION ITSELF -------------------------------------------
_WHO_G = "Right now garage is discharging while shed charges."
_WHO_S = "Right now shed is discharging while garage charges."
check("⚠️ THE ALARM NAMES THE TWO PACKS AND SAYS WHICH WAY EACH ONE IS PULLING. "
"`circulation.py` calls this the first thing somebody woken at 3 a.m. "
"needs and impossible to reconstruct from the watts alone, and until this "
"line the sentence could be deleted with all 26 files green",
_WHO_G in _msg_g)
check("⚠️ AND WITH THE TWO SWAPPED, THE SENTENCE SWAPS. This is the half a "
"single fixture cannot have: `pos` and `neg` exchanged, or a hard-coded "
"sentence, satisfies the check above and fails this one. The operator is "
"being told which pack to walk to",
_WHO_S in _msg_s)
check("...and each run carries ONLY its own sentence - the mirrored alarm does "
"not also contain the other direction, so the clause is derived from this "
"cycle's measurement rather than assembled from the roster",
_WHO_S not in _msg_g and _WHO_G not in _msg_s)
check("...and the RESTING pack is not named. `cellar` is on the roster, is read "
"every cycle and is measured at 5 W of standby draw - `opposing()` is "
"called with a floor of half the trip threshold precisely so a pack idling "
"beside a 6 kW loop is not reported as one of its two participants",
"cellar" not in _msg_g and "cellar" not in _msg_s
and _cg.inverters[2].batt == _REST_W)
check("...and the sentence sits with the figure, not instead of it: the same "
"card still carries the watts, the window and the three things only this "
"alarm can say",
all("CIRCULATING_POWER" in m and "METER CANNOT SEE" in m
and "CONTROL HAS BEEN STOPPED" in m and "isolator" in m
for m in (_msg_g, _msg_s)))
print()
print(" the operator's press is the only way out, and it clears BOTH halves")
@@ -0,0 +1,48 @@
# WHAT THIS BUILD IS SUPPOSED TO ANNOUNCE, recorded so that a change to the
# composition has something to disagree with. THREE COLUMNS:
#
# <entity_id> <unique_id> <device identifier>
#
# ⚠️ ALL THREE, AND THE SECOND AND THIRD ARE THE HALF THE FIRST DRAFT MISSED.
# A reference that carries only entity_ids cannot see the two changes that
# orphan an install without changing a single composed id:
#
# `BASE` renamed (`goodwe_ctl` -> anything) every unique_id changes
# `DEVICE["identifiers"]` changed HA sees a NEW device
#
# Home Assistant's MQTT integration keys its entity registry on `unique_id` and
# its device registry on the device identifier. Change either and HA creates a
# NEW entity beside the old one - and because discovery is published with
# `retain=True` and nothing publishes a removal payload, the OLD config stays on
# the broker and the dead entity stays in the registry, still named by every
# automation that referenced it, never updating again. The composed entity_id is
# identical on both, so an entity_id-only comparison reports a clean transition.
# That is the same shape as the habit-4 this file's own card already caught
# once: a reference that does not carry the thing that moved.
#
# ⚠️ AND `object_id` IS DELIBERATELY NOT A COLUMN. `app/mqtt.py` lines 36-42 and
# 61-76 record that HA 2026.8 does NOT honour `object_id` in a discovery
# payload - the id is composed from device name plus entity name - so renaming
# one is a true no-op and a column for it would make this file refuse a change
# that costs nothing. The check is graded on what HA actually keys on.
#
# PROVENANCE, stated exactly:
# - recorded 2026-09-08 from `release/1.0` at `meter_source: firmware`,
# roster of one, by reading `mqtt.discovery_payloads(units=[])`. It is what
# the code announces.
# - `sensor.goodwe_p1_sample_age` is additionally recorded in `app/mqtt.py` as
# VERIFIED AGAINST THE LIVE SYSTEM on 2026-08-26, and the ESP32's ingest
# guard subscribes to that exact string.
# - ⚠️ IT IS NOT A DUMP OF THE LIVE INSTALL'S ENTITY REGISTRY. That is what
# `--live-entities` is for. This file is about THE BUILD.
#
# Editing this file is how you say "the announcement changed on purpose". Do not
# edit it to make a check pass.
sensor.goodwe_setpoint goodwe_ctl_setpoint goodwe_rs485_controller
sensor.goodwe_grid_power goodwe_ctl_grid goodwe_rs485_controller
sensor.goodwe_battery_power goodwe_ctl_battery goodwe_rs485_controller
sensor.goodwe_battery_soc goodwe_ctl_soc goodwe_rs485_controller
sensor.goodwe_circulating_power goodwe_ctl_circulation goodwe_rs485_controller
sensor.goodwe_maintenance_phase goodwe_ctl_phase goodwe_rs485_controller
sensor.goodwe_controller_status goodwe_ctl_status goodwe_rs485_controller
sensor.goodwe_p1_sample_age goodwe_ctl_p1_age goodwe_rs485_controller
@@ -0,0 +1,22 @@
# ARCH-02 fixture: THE SHAPE THE LIVE BOX IS IN TODAY (a flat, single-inverter
# config). ⚠️ ILLUSTRATIVE ENTITY NAMES, NOT THE LIVE ONES - the owner runs the
# rehearsal against a copy of the real options blob, and this file exists so
# that the harness has a synthetic transition it can be graded on.
setpoint_entity: number.goodwe_setpoint
soc_entity: sensor.goodwe_battery_soc_pct
batt_entity: sensor.goodwe_battery_power_w
batt_invert: false
max_w: 5000
bms_soc_floor: 10
meter_source: 'off'
meter_entity: sensor.p1_active_power
inverters: []
gain: 0.6
slew_w: 0
deadband_w: 15
target_grid_w: 0
step_w: 10
heartbeat_s: 10
stale_input_s: 15
maintenance_discharge_w: 2000
maintenance_charge_w: 2000
@@ -0,0 +1,39 @@
# THE ENTITY IDS THE INSTALL HAS — a stand-in for the owner's real export.
#
# ⚠️ THIS FILE AND `announced-reference.txt` ANSWER DIFFERENT QUESTIONS AND ARE
# NOT INTERCHANGEABLE:
#
# announced-reference.txt what the BUILD announces. Loaded on EVERY run.
# Three columns, because HA keys on `unique_id` and
# on the device identifier as well as on the
# composed entity_id.
# this file (--live-entities)
# what the INSTALL has. One id per line. Orphans
# are graded against it: an id the install has and
# the AFTER shape does not announce is an orphan
# whatever the build's own history says.
#
# ⚠️ OMITTING `--live-entities` IS A REFUSAL (exit 2), NOT A QUIETER RUN. It
# used to be the other way round - an unreadable file refused, a missing flag
# passed with exit 0 and said nothing about the half it had not checked - and
# the owner runs this ONCE, at commissioning, under time pressure.
# `--no-live-entities` is the deliberate opt-out and it prints a standing
# banner in the report header and again on the exit line.
#
# PROVENANCE: these eight are what `release/1.0` announces at
# `meter_source: firmware`, roster of one, recorded 2026-09-08. They stand in
# for the export so the checks run in CI. ⚠️ THE REAL INSTALL CARRIES ROUGHLY 49
# `goodwe*` ENTITIES (card ARCH-02, read 2026-09-06); the other ~41 belong to
# the ESPHome firmware, to template sensors and to helpers, and this add-on
# announces none of them. The tool separates the ids it owns from the ids it
# does not rather than claiming orphans it cannot cause - which is the half of
# this file the eight-line stand-in cannot exercise, and the reason DOCS.md's
# owner step asks for the real export.
sensor.goodwe_battery_power
sensor.goodwe_battery_soc
sensor.goodwe_circulating_power
sensor.goodwe_controller_status
sensor.goodwe_grid_power
sensor.goodwe_maintenance_phase
sensor.goodwe_p1_sample_age
sensor.goodwe_setpoint
@@ -0,0 +1,42 @@
# ARCH-02 fixture: THE SHAPE AFTER INVERTER B IS COMMISSIONED.
#
# ⚠️ THE FIRST ROSTER ENTRY IS **NOT** CALLED `inverter-a`, AND THAT IS THE
# WHOLE FIXTURE. `MUTATION.md` habit 4's seventh instance is MAINT-05 shipping
# `migrate_maint_keys(store, UNIT_A, ...)` under a comment saying it must not:
# every two-pack fixture in the suite named its first entry `inverter-a`, so
# `UNIT_A` and `inverters[0].name` were the same string in every check that
# existed and the defect was invisible. Names that are not `inverter-a` are what
# make a misdirected namespace something a rehearsal can SEE.
#
# `roster-inverter-a.yaml` beside this file is the same roster with the first
# entry named `inverter-a`, so the two can be compared - see DOCS.md.
setpoint_entity: number.goodwe_setpoint
soc_entity: sensor.goodwe_battery_soc_pct
batt_entity: sensor.goodwe_battery_power_w
batt_invert: false
max_w: 5000
bms_soc_floor: 10
meter_source: 'off'
meter_entity: sensor.p1_active_power
gain: 0.6
slew_w: 0
deadband_w: 15
target_grid_w: 0
step_w: 10
heartbeat_s: 10
stale_input_s: 15
maintenance_discharge_w: 2000
maintenance_charge_w: 2000
inverters:
- name: inverter-a
setpoint_entity: number.goodwe_setpoint
soc_entity: sensor.goodwe_battery_soc_pct
batt_entity: sensor.goodwe_battery_power_w
max_w: 5000
bms_soc_floor: 10
- name: inverter-b
setpoint_entity: number.goodwe_b_setpoint
soc_entity: sensor.goodwe_b_battery_soc_pct
batt_entity: sensor.goodwe_b_battery_power_w
max_w: 4600
bms_soc_floor: 10
@@ -0,0 +1,42 @@
# ARCH-02 fixture: THE SHAPE AFTER INVERTER B IS COMMISSIONED.
#
# ⚠️ THE FIRST ROSTER ENTRY IS **NOT** CALLED `inverter-a`, AND THAT IS THE
# WHOLE FIXTURE. `MUTATION.md` habit 4's seventh instance is MAINT-05 shipping
# `migrate_maint_keys(store, UNIT_A, ...)` under a comment saying it must not:
# every two-pack fixture in the suite named its first entry `inverter-a`, so
# `UNIT_A` and `inverters[0].name` were the same string in every check that
# existed and the defect was invisible. Names that are not `inverter-a` are what
# make a misdirected namespace something a rehearsal can SEE.
#
# `roster-inverter-a.yaml` beside this file is the same roster with the first
# entry named `inverter-a`, so the two can be compared - see DOCS.md.
setpoint_entity: number.goodwe_setpoint
soc_entity: sensor.goodwe_battery_soc_pct
batt_entity: sensor.goodwe_battery_power_w
batt_invert: false
max_w: 5000
bms_soc_floor: 10
meter_source: 'off'
meter_entity: sensor.p1_active_power
gain: 0.6
slew_w: 0
deadband_w: 15
target_grid_w: 0
step_w: 10
heartbeat_s: 10
stale_input_s: 15
maintenance_discharge_w: 2000
maintenance_charge_w: 2000
inverters:
- name: garage
setpoint_entity: number.goodwe_setpoint
soc_entity: sensor.goodwe_battery_soc_pct
batt_entity: sensor.goodwe_battery_power_w
max_w: 5000
bms_soc_floor: 10
- name: shed
setpoint_entity: number.goodwe_b_setpoint
soc_entity: sensor.goodwe_b_battery_soc_pct
batt_entity: sensor.goodwe_b_battery_power_w
max_w: 4600
bms_soc_floor: 10
@@ -0,0 +1,11 @@
{
"_comment": "ARCH-02 SYNTHETIC FIXTURE - a store that has ALREADY run the flat config under 0.7.0+, so DEV-02's `schema_version` is stamped and the SAFETY-02 latch is not tripped. NOT a copy of the live box's /data/state.json. This is the CLEAN CONTROL for the MAINT-05 half of the rehearsal: the flat->roster transition over it must come out with NO findings, which is what makes each deliberate mutation's RED mean something. A harness that is red on every input has not measured anything.",
"schema_version": 1,
"phase": "idle",
"phase_started": "2026-08-07T10:00:00+00:00",
"last_completed": "2026-08-07T18:42:00+00:00",
"last_start_attempt": "2026-08-07T10:00:00+00:00",
"sign_tripped": false,
"sign_reason": "",
"auto": true
}
@@ -0,0 +1,3 @@
{
"_comment": "ARCH-02 SYNTHETIC FIXTURE - a FRESH INSTALL. No maintenance history, no latch, no schema markers: what /data/state.json looks like before the add-on has ever completed a cycle. It is a clean control, and it earned its place: the first draft of the harness reported this transition RED, because the DEV-02 migration writes both SAFETY-02 defaults under `inverter-a` before a roster exists and the tool called them stranded. Losing a default loses nothing."
}
@@ -0,0 +1,12 @@
{
"_comment": "INSTR-01 SYNTHETIC FIXTURE - `state-legacy.json` with a SECOND roster unit already latched. Not a copy of any live state file. It exists to restore M12 END-TO-END on the STAGED leg, which is the arrangement SAFETY-44 and INSTR-01's first draft both missed: `snapshot_over()` starts the after-leg over the file the BEFORE-leg wrote, so a transition-made destination is available there and the two questions the provenance clause and the narrowing ask are NOT incompatible. Every same-step arrangement makes them incompatible; that is what was measured and over-generalised to 'no fixture can'.",
"phase": "idle",
"phase_started": "2026-08-07T10:00:00+00:00",
"last_completed": "2026-08-07T18:42:00+00:00",
"last_start_attempt": "2026-08-07T10:00:00+00:00",
"sign_tripped": true,
"sign_reason": "SIGN_DISAGREEMENT: commanded -1800 W against a meter reading -1450 W for 190 s",
"auto": true,
"unit/shed/sign_tripped": true,
"unit/shed/sign_reason": "SIGN_DISAGREEMENT: shed, latched before the switch"
}
@@ -0,0 +1,10 @@
{
"_comment": "ARCH-02 SYNTHETIC FIXTURE - a PRE-DEV-02 flat state file. NOT a copy of the live box's /data/state.json; this session cannot read that one and did not try. It is built to carry, deliberately, the two shapes that state built by the happy path does not have: a LATCHED SAFETY-02 failsafe under the flat spelling, and a real maintenance history under the flat spelling. Both are LEGACY-SHAPED: there is no schema_version and no maint_schema_version, so this file was last written by a controller that had never heard of unit namespaces. Losing either one is silent.",
"phase": "idle",
"phase_started": "2026-08-07T10:00:00+00:00",
"last_completed": "2026-08-07T18:42:00+00:00",
"last_start_attempt": "2026-08-07T10:00:00+00:00",
"sign_tripped": true,
"sign_reason": "SIGN_DISAGREEMENT: commanded -1800 W against a meter reading -1450 W for 190 s",
"auto": true
}
File diff suppressed because it is too large Load Diff