Compare commits

...
10 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
6 changed files with 922 additions and 150 deletions
+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]}")
+433 -75
View File
@@ -411,6 +411,94 @@ check("...and `phase` is NOT among them, because its value 'idle' IS the "
"phase" not in _m9_vanished)
shutil.rmtree(Path(_m9).parent, ignore_errors=True)
# ⭐ `M12` IS BACK, AND THE CLAIM THAT KILLED IT WAS WRONG.
#
# M12 is SAFETY-44's end-to-end control for the off-roster narrowing: a mutant
# makes one ROSTER unit's latch key vanish while a sibling holds the same
# value, and it must go red when the narrowing is removed. INSTR-01's first
# draft DELETED it, on the claim that the provenance clause made it
# unrestorable and that "NO FIXTURE CAN RESTORE IT, measured both ways".
#
# ⚠️ TWO MEASUREMENTS ARE NOT AN IMPOSSIBILITY PROOF, AND THIS ONE WAS FALSE.
# Both arrangements I measured were SAME-STEP shaped, and the structural
# argument - `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 on the staged leg a transition-made
# destination is available and the two questions stop being incompatible.
# Review found the third arrangement I had generalised past.
#
# HOW IT WORKS. The fixture is `state-legacy.json` plus a second roster unit
# already latched. The mutant makes `shed` POP its own latch key and come up
# untripped - a truer SAFETY-02 loss than the original, which left `tripped`
# True in memory. On the staged leg, losing shed's latch makes
# `_grade_store_migration()` fall through `on_roster_latched` and RE-ARM
# `unit/garage/sign_tripped`, which is absent from `before.data` - so the
# destination is transition-made and C7 lets it through. The off-roster
# `inverter-a` litter is present in BOTH snapshots on this leg, so it never
# vanishes and never claims the destination, and C6 stays out of the way.
#
# ⚠️ AND WHAT ACTUALLY BROKE WAS THE PRECONDITION, NOT THE CONTROL. M12's
# `exit == EXIT_OK` baseline does not survive a legacy-shaped fixture. 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 that
# what closed the first draft's gap is `_m12_new` BEING A SET DIFFERENCE. A
# stable dirty baseline is fine for a set difference. So the control was
# deleted on the strength of a precondition the same commit had already
# characterised as not load-bearing - which is a worse mistake than the
# original inert check, because it removed coverage rather than failing to add
# it.
_M12_OLD = 'tripped=bool(data.get(self.key("sign_tripped"), False)),'
# ⚠️ THE MUTANT POPS **AND COMES UP UNTRIPPED**, which is the whole point and
# which the first draft got wrong: `data.pop(k, False)` RETURNS the value, so
# shed stayed latched in memory, `_grade_store_migration()` saw
# `on_roster_latched` and never re-armed garage - no transition-made
# destination, and the finding was reported whether or not C5 existed. The
# control looked restored and discriminated nothing. `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.
_M12_NEW = ('tripped=bool(data.pop(self.key("sign_tripped"), False) and False'
+ chr(10) + ' if self.name == "shed" else'
+ chr(10) + ' data.get(self.key("sign_tripped"), False)),')
def m12_findings(tree):
"""The finding SET of a staged-leg run over the legacy+shed fixture."""
code, out = run(Path(tree), "state-legacy-plus-shed.json", "roster.yaml",
"--paths", "staged")
return code, set(re.findall(r"⚠️ ([A-Z ]+): (\S+)", out)), out
_m12 = mutant_tree([("app/inverter.py", _M12_OLD, _M12_NEW)])
_m12_clean_code, _m12_clean, _ = m12_findings(HERE)
_m12_code, _m12_mut, _ = m12_findings(Path(_m12))
_m12_new = _m12_mut - _m12_clean
print(f" staged leg, legacy+shed: shipped exit {_m12_clean_code}, "
f"one roster unit's latch popped -> exit {_m12_code}")
# ⚠️ THE PRINTED SET IS LOSSIER THAN IT LOOKS: the regex captures the first
# TOKEN of each finding's text, so two findings of one kind collapse into one
# tuple. Harmless here - the difference is one-directional and the pair M12
# asserts on is exact - but do not read this line as an inventory.
print(f" findings the mutation ADDS (first token only): {sorted(_m12_new)}")
check("⚠️ PRECONDITION: the baseline is STABLE and does not already carry the "
"finding this mutation exists to produce. It is NOT clean - a legacy "
"fixture never is - and the SET DIFFERENCE is what makes that fine, "
"which is the sentence the deletion should have read before acting on it",
not any(k == "unit/shed/sign_tripped" for _, k in _m12_clean))
# ⚠️ AND IT MATCHES THE **KIND** AS WELL AS THE KEY, which SAFETY-44's original
# did not. Review measured it: renaming the finding kind in `diff()` from
# "VANISHED STORE KEY" to "GONE STORE KEY" left M12 GREEN. A vanished latch
# quietly downgraded to a softer heading is exactly the shape part 3 requires
# every mutation to be graded against - "the SPECIFIC finding it should produce"
# - and this file was not holding itself to its own rule.
check("⭐ M12 RESTORED: a ROSTER unit's latch key that vanishes is a "
"VANISHED STORE KEY finding even though a sibling holds True - and this "
"is the STAGED leg, where `snapshot_over()` supplies a transition-made "
"destination, so the provenance clause does not foreclose the narrowing",
("VANISHED STORE KEY", "unit/shed/sign_tripped") in _m12_new)
shutil.rmtree(Path(_m12).parent, ignore_errors=True)
# ⚠️ AND THE INPUT FILE IS NEVER WRITTEN. The owner runs this against a copy of
# a live state file; a tool that edited it would be a tool that had to be
# trusted rather than one that cannot do harm.
@@ -587,33 +675,32 @@ shutil.rmtree(Path(_tree2).parent, ignore_errors=True)
# --------------------------------------------------------------------------- #
print()
print("5. SAFETY-44: a pre-DEV-02 flat store meeting a DECLARED ROSTER, which "
"is the path nobody had established either way")
print("5. SAFETY-44: a pre-DEV-02 flat store meeting a DECLARED ROSTER - "
"FIXED, with each half of the fix as its own negative control")
# --------------------------------------------------------------------------- #
# The question on the card: `migrate_unit_keys(store, UNIT_A)` takes a hardcoded
# constant and has no `misdirected` guard, while `migrate_maint_keys(store,
# unit, roster)` takes the roster and does. Is that asymmetry safe?
# The question on the card: `migrate_unit_keys(store, UNIT_A)` took a hardcoded
# constant and had no `misdirected` guard, while `migrate_maint_keys(store,
# unit, roster)` takes the roster and does. That asymmetry was NOT safe, and
# this part used to assert the defect. It now asserts the FIX.
#
# ⚠️ MEASURED, NOT ARGUED. The two checks below are the answer, and they are
# written as REQUIREMENTS so that a fix to `migrate_unit_keys` turns them red
# and this file has to be updated deliberately rather than drifting.
# ⚠️ THE FIX IS TWO EDITS AND THEY DO DIFFERENT JOBS, WHICH IS THE WHOLE REASON
# THIS PART IS SHAPED THE WAY IT IS. Measured, per leg, by reverting each edit
# on its own (5b and 5c below):
#
# SAME-STEP leg STAGED leg
# both edits (shipped) clean latch armed, litter
# 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. Saying
# that plainly matters more than it looks: a reader who assumes the roster-aware
# migration is what re-arms the latch would delete the guard as redundant during
# a tidy-up and take the STAGED column with it - and STAGED is the path
# "upgrade now, declare the roster later" produces, which is the path an
# operator actually takes.
code44, out44 = run(HERE, "state-legacy.json", "roster.yaml")
print(f" flat store + roster ['garage','shed'] -> exit {code44}")
check("⚠️ SAFETY-44 IS A DEFECT AGAIN: a store that ran pre-DEV-02 flat and "
"declares a multi-inverter roster comes up with its SAFETY-02 latch "
"DISARMED - the file says `sign_tripped: true` and not one inverter is "
"latched",
code44 == RR.EXIT_FINDING and "LATCH CAME UP DISARMED" in out44)
check("...and the harness names the two latch keys SAFETY-44 asks about, by "
"the namespace they were stranded in",
"unit/inverter-a/sign_tripped" in out44
and "unit/inverter-a/sign_reason" in out44)
check("...and the mechanism is visible in the report: the keys are stranded "
"under `inverter-a`, which is not on the roster, so no machine reads them",
"STRANDED STORE KEY" in out44 and "['garage', 'shed']" in out44)
# ⚠️ PER LEG, NOT A COUNT OVER THE WHOLE REPORT. See `legs()` for the three
# builds this distinction was measured on and for what the count missed.
_L = legs(out44)
print(f" flat store + roster ['garage','shed'] -> exit {code44}")
print(f" legs in the report: {sorted(_L)}")
for _leg in ("STAGED", "SAME-STEP"):
print(f" LEG {_leg}: "
@@ -622,77 +709,348 @@ check("the report is split into legs this check can grade SEPARATELY - the "
"tool prints a `LEG <id>` marker, because counting a finding's text over "
"the whole report cannot tell one red leg from two",
set(_L) == {"STAGED", "SAME-STEP"})
check("⚠️ SAME-STEP is disarmed: the roster is declared in the SAME step as "
"the upgrade, the migration runs and misdirects the latch to "
"`inverter-a`",
"LATCH CAME UP DISARMED" in _L.get("SAME-STEP", ""))
check("⚠️ AND IT IS NOT THE SAME-STEP PATH ALONE - the STAGED leg reaches the "
"same disarmed latch by a DIFFERENT route: the flat start stamps "
"`schema_version`, so the roster start's migration takes its early "
"return and the latch never moves at all",
"LATCH CAME UP DISARMED" in _L.get("STAGED", ""))
# ⚠️ AND THE CONTRAST, WHICH IS WHAT STOPS THE CHECK ABOVE BEING VACUOUS. The
# SAME state file onto a roster whose first entry IS `inverter-a` comes up
# LATCHED. The two runs differ in one thing - the first roster name - so the
# finding is attributable to it and to nothing else.
code_ok, out_ok = run(HERE, "state-legacy.json", "roster-inverter-a.yaml")
check("⭐ SAFETY-44 IS FIXED ON BOTH PATHS: a store that ran pre-DEV-02 flat "
"and declares a multi-inverter roster comes up LATCHED - neither leg "
"reports a disarmed guard",
"LATCH CAME UP DISARMED" not in out44)
check("...and the latch is under a name THE ROSTER READS, which is the whole "
"of the claim - `garage` is on the roster and `inverter-a` is not",
all("after : which units came up latched : ['garage']" in v
for v in _L.values()))
check("⚠️ the SAME-STEP leg is now CLEAN OUTRIGHT: the migration writes the "
"latch straight into the roster's first namespace, so there is no "
"stranded copy left behind and nothing for an operator to read past",
"none in LEG SAME-STEP" in _L.get("SAME-STEP", ""))
check("⚠️ ...while the STAGED leg still reports the two STRANDED keys, and "
"that is REPORTED not hidden: its flat start stamped `schema_version` "
"before the roster existed, so the migration takes its early return and "
"the old copies stay in the file with nothing reading them. The latch is "
"armed anyway - by the guard - and the litter is true",
"STRANDED STORE KEY" in _L.get("STAGED", "")
and "unit/inverter-a/sign_tripped" in _L.get("STAGED", ""))
check("...and the run as a whole is a FINDING rather than silence, because "
"keys nothing reads are still keys nothing reads",
code44 == RR.EXIT_FINDING)
check("...and no leg's text has swallowed the CLOSING SUMMARY, which repeats "
"every finding from every leg - the failure that made the last leg read "
"red whenever any leg was",
not any("FINDING(S) - this transition is not clean" in v
for v in _L.values()))
# ⚠️ THIS BLOCK USED TO SAY THOSE HOLES WERE KNOWN AND NOT FIXED, AND IT IS
# NOW STALE IN THE OTHER DIRECTION - INSTR-01 fixed them, and left the
# paragraph saying "BOTH ARE REVERTED OUT OF THIS CARD, because THIS SUITE
# CANNOT MEASURE EITHER ONE" describing a tree that no longer exists. Review
# found it. A comment that outlives its subject is the same defect as a
# citation that outlives its check.
#
# WHERE EACH ONE NOW LIVES, and every clause of `moved_to` has a row in 5e:
#
# INJECTIVITY (C6) R5, and removing the clause flips exactly R5
# PROVENANCE (C7) R3, likewise
# SAME VALUE (C3) R4
# SAME BARE KEY(C2) R7 - which had NO row until review deleted the clause
# and the whole 28-file suite stayed green
# MACHINE-READ (C1) R6
# OFF-ROSTER (C5) R2, and M12 end-to-end on the staged leg
# PREFIX GATE (C4) NO 5e ROW, deliberately - `vanished_unit is not None`
# is reached end-to-end by part 3's M9, and a synthetic
# row would duplicate that rather than add separability.
# Listed because C5 lives in the same `if` and omitting
# one guard while naming the other reads as an oversight.
#
# ⚠️ ONE HOLE IS STILL OPEN AND IS **UNMEASURED**, stated so nobody reads
# `sorted()` as guarded: ATTRIBUTION. Which vanished key gets a destination is
# decided by `sorted()` order, so on a build renaming `inverter-b` -> `garage`
# while DELETING `inverter-a`, the deleted key sorts first and takes it - the
# report names the DELETED key as migrated and the one that really moved as
# VANISHED. Review measured that replacing `sorted(` with `list(` leaves the
# suite green: every 5e case has at most one candidate destination, so no row
# can see ordering. The run is still exit 1, so this is a WRONG SENTENCE rather
# than a hidden loss - an operator chasing the named key looks at the wrong
# inverter. Fixing it needs the destination's provenance rather than
# first-come, which is more than a rename diff currently knows.
check("⚠️ and the key that MOVED is not reported as lost: the before-snapshot "
"holds `unit/inverter-a/sign_tripped = True` and the after-snapshot "
"holds it under `garage`, three lines apart in the same report. A "
"VANISHED finding there is the harmless-case alarm CONTROL-C exists to "
"prevent",
"migrated unit/inverter-a/sign_tripped = True -> "
"unit/garage/sign_tripped" in out44
and "VANISHED STORE KEY" not in out44)
# --------------------------------------------------------------------------- #
print()
print("5b. THE SPLITTER'S POSITIVE CONTROL: a build in which exactly ONE leg "
"is clean")
print("5b. NEGATIVE CONTROL, AND THE SPLITTER'S POSITIVE ONE: revert THE "
"GUARD, and exactly one leg goes red")
# --------------------------------------------------------------------------- #
# ⚠️ A PER-LEG CHECK THAT HAS NEVER SEEN THE LEGS DISAGREE IS A COUNT WITH
# EXTRA STEPS. Every fixture above has both legs red, so nothing so far
# separates "reads each leg" from "reads the whole report twice". This part
# builds the one case that does: `migrate_unit_keys()` handed the roster's own
# first name instead of the hardcoded `UNIT_A`.
#
# ⚠️ AND WHAT IT MEASURES IS ALSO THE ANSWER TO "IS THAT THE FIX". It is NOT.
# It repairs the SAME-STEP path only. The STAGED path is untouched, because
# there the flat start stamps `schema_version = 1` and the roster start's
# migration takes its `from_version >= SCHEMA_VERSION` early return before the
# name it would be handed matters at all. A fix that stops at this line would
# close the half an operator is least likely to take and leave the half that
# "upgrade first, declare the roster later" produces.
_one_leg = mutant_tree([(
# EXTRA STEPS, and MUTATION.md's extension says one that has seen them disagree
# in a single arrangement is a total over that arrangement. This build is the
# one that makes them disagree, and it is a negative control for part 5 at the
# same time: put back the clause that counted ANY `unit/*/sign_tripped` as
# protection, including one under a name no unit answers to.
# ⚠️ WHAT THIS CONTROL PINS IS THE SHIPPED SPELLING, NOT THE RULE, and review
# is owed that distinction. `mutant_tree()` asserts its target exists - rightly,
# because a mutation that did not happen yields a fabricated result - but the
# consequence is that EDITING the guard's line makes this file red at the
# ANCHOR before any behaviour is measured. Demonstrated: rewriting the same
# semantics as `on_roster = set()` leaves the anchor intact and IS caught, by
# M4's control; rewriting the line itself is caught by the anchor, which looks
# identical from the exit code and proves much less. Read 5b and 5c as controls
# over this text, and the behavioural coverage as living in test_maintenance.py
# section 13 and test_inverter.py, which construct stores rather than strings.
_no_guard = mutant_tree([(
"app/main.py",
" self.store_migration = migrate_unit_keys(store, UNIT_A)",
" self.store_migration = migrate_unit_keys(store, "
"roster_names(opts)[0])")])
_ol_code, _ol_out = run(Path(_one_leg), "state-legacy.json", "roster.yaml")
_OL = legs(_ol_out)
" if unit in on_roster:",
" if True: # MUTANT: any namespaced latch is protection")])
_ng_code, _ng_out = run(Path(_no_guard), "state-legacy.json", "roster.yaml")
_NG = legs(_ng_out)
for _leg in ("STAGED", "SAME-STEP"):
print(f" LEG {_leg}: "
f"{'DISARMED' if 'LATCH CAME UP DISARMED' in _OL.get(_leg, '') else 'clean'}")
check("⭐ with `migrate_unit_keys()` handed the roster's own first name, the "
"SAME-STEP leg comes up CLEAN - so the splitter really does read the "
"legs separately, and a check that only counted would have missed this",
"LATCH CAME UP DISARMED" not in _OL["SAME-STEP"])
check("⚠️ ...while the STAGED leg is STILL DISARMED on the same build: that "
"line is not the whole fix, because the staged path never reaches it - "
"the first start stamps `schema_version` and the second start's "
"migration returns early",
"LATCH CAME UP DISARMED" in _OL["STAGED"])
check("...and the run as a whole is still a finding, so nobody reads this "
"partial fix as done",
_ol_code == RR.EXIT_FINDING)
shutil.rmtree(Path(_one_leg).parent, ignore_errors=True)
f"{'DISARMED' if 'LATCH CAME UP DISARMED' in _NG.get(_leg, '') else 'clean'}")
check("⭐ with the off-roster clause reverted the STAGED leg comes up "
"DISARMED - so the guard's roster test is load-bearing, and it is the "
"half of the fix that repairs an install already stamped by the shipped "
"version",
"LATCH CAME UP DISARMED" in _NG.get("STAGED", ""))
check("⚠️ ...while the SAME-STEP leg stays ARMED on the very same build - the "
"legs DISAGREE, so the splitter really does read them separately and a "
"check that only counted findings would have missed this",
"LATCH CAME UP DISARMED" not in _NG.get("SAME-STEP", ""))
check("...and the run is a finding, so the partial build is never read as "
"clean",
_ng_code == RR.EXIT_FINDING)
shutil.rmtree(Path(_no_guard).parent, ignore_errors=True)
# --------------------------------------------------------------------------- #
print()
print("5c. back to the shipped code")
print("5c. NEGATIVE CONTROL: revert THE CALL SITE, and the litter comes back "
"without the latch going out")
# --------------------------------------------------------------------------- #
# ⚠️ THIS IS THE CONTROL THAT KEEPS 5b HONEST ABOUT WHAT IT PROVED. 5b shows
# the guard carries the latch; on its own that is an argument for deleting the
# roster-aware migration as decoration. This build is the measurement instead:
# hand `migrate_unit_keys()` the hardcoded `UNIT_A` again, with the guard left
# fixed. The latch stays armed - the guard catches it - and the SAME-STEP leg
# gains the two stranded keys it does not have on the shipped code. The
# migration fix buys a store with nothing dead in it, and buys the
# `misdirected` refusal that stops a WRONG marker being stamped; it does not
# buy the latch. Both edits stay.
_CALL_NOW = (" self.store_migration = migrate_unit_keys(" + chr(10) +
" store, _roster_names[0], _roster_names)")
_old_call = mutant_tree([(
"app/main.py", _CALL_NOW,
" self.store_migration = migrate_unit_keys(store, UNIT_A)")])
_oc_code, _oc_out = run(Path(_old_call), "state-legacy.json", "roster.yaml")
_OC = legs(_oc_out)
for _leg in ("STAGED", "SAME-STEP"):
print(f" LEG {_leg}: "
f"{'DISARMED' if 'LATCH CAME UP DISARMED' in _OC.get(_leg, '') else 'clean'}")
check("⚠️ with `UNIT_A` handed back to the migration the latch is STILL "
"armed on both legs - the guard is what re-arms it, and saying so is "
"the difference between this fix and a story about it",
"LATCH CAME UP DISARMED" not in _oc_out)
check("⭐ ...but the SAME-STEP leg is no longer clean: the two SAFETY-02 keys "
"are written under `inverter-a` on a roster of ['garage', 'shed'] and "
"nothing reads them - which is the state the shipped code does not "
"produce",
"STRANDED STORE KEY" in _OC.get("SAME-STEP", "")
and "none in LEG SAME-STEP" not in _OC.get("SAME-STEP", ""))
shutil.rmtree(Path(_old_call).parent, ignore_errors=True)
# --------------------------------------------------------------------------- #
print()
print("5d. THE CONTRAST: the same file onto a roster that keeps the name")
# --------------------------------------------------------------------------- #
# ⚠️ AND THE CONTRAST, WHICH IS WHAT STOPS EVERY CHECK ABOVE BEING VACUOUS. The
# SAME state file onto a roster whose first entry IS `inverter-a` comes up
# LATCHED AND CLEAN. The two runs differ in one thing - the first roster name -
# so a finding above is attributable to it and to nothing else.
code_ok, out_ok = run(HERE, "state-legacy.json", "roster-inverter-a.yaml")
check("the SAME state file onto a roster whose first entry IS `inverter-a` "
"comes up latched and clean - so the finding above is caused by the "
"roster NAME and not by the fixture being tripped",
"comes up latched and clean - so anything part 5 reports is caused by "
"the roster NAME and not by the fixture being tripped",
code_ok == RR.EXIT_OK and "LATCH CAME UP DISARMED" not in out_ok)
# --------------------------------------------------------------------------- #
print()
print("5e. INSTR-01: `diff()`'s suppression rule, ONE FIXTURE PER CLAUSE")
# --------------------------------------------------------------------------- #
# ⚠️ THIS SECTION EXISTS BECAUSE SAFETY-44 PRODUCED THREE INERT CONTROLS FOR
# THIS ONE RULE, EACH INERT DIFFERENTLY:
#
# 1. a check over part 5's real destinations passed with the whole mechanism
# deleted - its two destinations are `sign_reason` and `sign_tripped`,
# DIFFERENT bare keys, which can never contend;
# 2. a dedicated end-to-end fixture discriminated nothing - both snapshots
# read ONE state file, so nothing vanishes and there are no migrated lines
# with the clause or without it;
# 3. rebuilding M12's fixture to reach a new clause DISARMED M12 for an older
# one, and all three clause-removals went green at once.
#
# ⭐ (3) IS THE STRUCTURAL FINDING AND IT IS WHY THIS SECTION IS SHAPED THIS WAY.
# `moved_to` is ONE comprehension carrying FIVE conditions with five different
# reasons to exist, and an END-TO-END fixture cannot separate them: both
# snapshots come from one state file, so the arrangement that reaches clause N
# forecloses clause M. Rebuilding the fixture to reach one moves the others.
#
# So the rule is graded at the level it is written at - `diff()` on constructed
# Snapshots - with ONE CASE PER CLAUSE. Nothing here is a fiction: the inputs
# are the same three fields `diff()` reads (`data`, `machine_read`, `units`),
# and the end-to-end controls above - M12 on the STAGED leg, and part 5 -
# still grade the same rule through a real start. This is the separability
# those cannot have, not a replacement for them.
CLAUSE_CASES = {
# name: (units, machine_read, before, after)
"R1-rename-legit": (
["garage"], ["unit/garage/sign_tripped"],
{"unit/old/sign_tripped": True}, {"unit/garage/sign_tripped": True}),
"R2-narrowing": (
["garage", "shed"], ["unit/garage/sign_tripped", "unit/shed/sign_tripped"],
{"unit/shed/sign_tripped": True}, {"unit/garage/sign_tripped": True}),
"R3-provenance": (
["garage"], ["unit/garage/sign_tripped"],
{"unit/old/sign_tripped": True, "unit/garage/sign_tripped": True},
{"unit/garage/sign_tripped": True}),
"R4-value": (
["garage"], ["unit/garage/sign_reason"],
{"unit/old/sign_reason": "X-unique"},
{"unit/garage/sign_reason": "Y-different"}),
"R5-injectivity": (
["garage"], ["unit/garage/sign_tripped"],
{"unit/old-a/sign_tripped": True, "unit/old-b/sign_tripped": True},
{"unit/garage/sign_tripped": True}),
"R6-machine-read": (
["garage"], [],
{"unit/old/sign_tripped": True}, {"unit/garage/sign_tripped": True}),
# ⚠️ R7 EXISTS BECAUSE THE BARE-KEY MATCH HAD NO ROW AT ALL, and review
# deleted it and left the whole 28-file suite GREEN. 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. It is load-bearing: a vanished
# SAFETY-02 `sign_tripped` was suppressed by a SAFETY-42
# `delivery_tripped` on a roster unit, i.e. by a DIFFERENT FAILSAFE's key.
# `unit/*/delivery_tripped` is machine-read, so this is reachable on any
# real two-latch store rather than a contrivance.
"R7-bare-key": (
["garage"], ["unit/garage/delivery_tripped"],
{"unit/old/sign_tripped": True},
{"unit/garage/delivery_tripped": True}),
}
# What the SHIPPED rule must answer. `migrated` is the suppression firing.
CLAUSE_EXPECT = {
"R1-rename-legit": ([], ["unit/old/sign_tripped"]),
"R2-narrowing": (["unit/shed/sign_tripped"], []),
"R3-provenance": (["unit/old/sign_tripped"], []),
"R4-value": (["unit/old/sign_reason"], []),
"R5-injectivity": (["unit/old-b/sign_tripped"], ["unit/old-a/sign_tripped"]),
"R6-machine-read": (["unit/old/sign_tripped"], []),
"R7-bare-key": (["unit/old/sign_tripped"], []),
}
# ⚠️ THE DRIVER **IMPORTS** THE TREE UNDER TEST AND THE TABLE STAYS HERE, so
# expectation and implementation sit on opposite sides of the subprocess and
# a mutation to the tool cannot also move the answer it is graded against.
# (It is WRITTEN to a tempdir - see `clause_table()`. The first draft of this
# sentence said "written into the tree", which is the read-only-CI bug the
# same commit says it fixed, stated as if it were the design.)
CLAUSE_DRIVER = """
import json, sys
import rehearse_roster as RR
spec = json.load(sys.stdin)
out = {}
for name, case in spec.items():
units, mread, before, after = case
b = object.__new__(RR.Snapshot); a = object.__new__(RR.Snapshot)
for snap, data in ((b, before), (a, after)):
snap.label = name
snap.data = dict(data)
snap.units = list(units)
snap.machine_read = set(mread)
snap.entities = {}
snap.latched = {u: False for u in units}
snap.latch_reason = {u: "" for u in units}
snap.flat_tripped = False
snap.history = {}
snap.read = set()
snap.stranded_at_default = []
snap.vanished_at_default = []
snap.vanished_but_migrated = []
f = RR.diff(b, a)
out[name] = [
sorted(x.text.split(" = ")[0] for x in f
if x.kind == "VANISHED STORE KEY"),
sorted(t.split(" = ")[0] for t in a.vanished_but_migrated),
]
print(json.dumps(out))
"""
def clause_table(tree) -> dict:
"""Run CLAUSE_CASES through `diff()` in `tree`, as the tool would see it."""
# ⚠️ THE DRIVER GOES IN A TEMPDIR, NOT IN THE TREE. `checks / unit` mounts
# the checkout READ-ONLY, so writing beside the tool works on a dev box and
# is `OSError: Read-only file system` in CI - a difference that would have
# turned this section red on every PR and green for whoever wrote it.
tools = Path(tree) / "tools"
tmp = tempfile.mkdtemp(prefix="clause-drv-")
drv = Path(tmp) / "_clause_driver.py"
drv.write_text(CLAUSE_DRIVER, encoding="utf-8")
proc = subprocess.run(
[sys.executable, str(drv)],
input=json.dumps(CLAUSE_CASES), capture_output=True, text=True,
cwd=tmp,
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1",
"PYTHONPATH": f"{tools}{os.pathsep}{Path(tree)}"})
shutil.rmtree(tmp, ignore_errors=True)
assert proc.returncode == 0, (
"the clause driver did not run, so every row below would be "
f"fabricated:\n{proc.stdout}\n{proc.stderr}")
return {k: (v[0], v[1]) for k, v in json.loads(proc.stdout).items()}
_ct = clause_table(HERE)
for _name in sorted(CLAUSE_CASES):
_got, _want = _ct[_name], CLAUSE_EXPECT[_name]
print(f" {_name:<18} vanished={_got[0]} migrated={_got[1]}")
check(f"{_name}: the shipped rule answers as specified", list(_got) == [list(_want[0]), list(_want[1])])
check("⚠️ ...and the LEGITIMATE case is still suppressed - a rule that reports "
"every rename is a rule an operator turns off, which is the failure "
"CONTROL-C exists to prevent",
_ct["R1-rename-legit"][1] == ["unit/old/sign_tripped"])
# ⭐ AND EACH CLAUSE REMOVAL MUST FLIP **EXACTLY ITS OWN ROW**. That second half
# is the acceptance criterion this card was filed with: a removal that flips
# nothing is an unmeasured clause, and a removal that flips someone else's row
# means the cases are not separable after all - which is (3) above, caught.
CLAUSE_MUTATIONS = [
("C2 same bare key", "R7-bare-key",
' if k.rsplit("/", 1)[-1] == bare and after.data.get(k) == value',
" if True and after.data.get(k) == value"),
("C1 machine_read", "R6-machine-read",
" k for k in after.machine_read",
" k for k in after.data"),
("C3 same value", "R4-value",
" and after.data.get(k) == value\n", "\n"),
("C5 off-roster", "R2-narrowing",
" if vanished_unit is not None and vanished_unit not in after.units \\\n"
" and moved_to:",
" if vanished_unit is not None and moved_to:"),
("C6 injectivity", "R5-injectivity",
" and k not in claimed)", ")"),
("C7 provenance", "R3-provenance",
" and before.data.get(k) != value", " and True"),
]
for _label, _owns, _old, _new in CLAUSE_MUTATIONS:
_t = mutant_tree([("tools/rehearse_roster.py", _old, _new)])
_mt = clause_table(_t)
_flipped = sorted(n for n in CLAUSE_CASES if _mt[n] != _ct[n])
print(f" {_label:<16} removed -> rows that changed: {_flipped}")
check(f"⭐ removing {_label} flips {_owns} - the clause is load-bearing and "
f"this row is what measures it",
_flipped == [_owns])
shutil.rmtree(Path(_t).parent, ignore_errors=True)
# --------------------------------------------------------------------------- #
print()
print("6. THE REVERSE DIRECTION: the retreat, and the return from it")
@@ -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"
}
@@ -460,6 +460,7 @@ class Snapshot:
# finding: see `diff()`.
self.stranded_at_default = []
self.vanished_at_default = []
self.vanished_but_migrated = []
@property
def unit_keys(self):
@@ -544,12 +545,84 @@ def diff(before: Snapshot, after: Snapshot) -> list:
# name and whose roster start writes them under another reported two
# VANISHED findings about a pair of defaults, and CONTROL-C went red on a
# build that was CORRECT.
# ⚠️ `claimed` MAKES THE MOVE INJECTIVE. Without it N vanished keys can all
# claim the SAME destination - and `DEFAULTS["sign_tripped"] is False`, so
# `True` is the ONLY non-default value a latch can hold, which means they
# collide by CONSTRUCTION rather than by coincidence. Two keys cannot both
# have become one key; the second claimant has not moved anywhere.
claimed = set()
for key in sorted(set(before.data) - set(after.data)):
value = before.data[key]
bare = key.rsplit("/", 1)[-1]
if value in (None, DEFAULTS.get(bare)):
after.vanished_at_default.append(f"{key} = {value!r}")
continue
# ⚠️ AND A KEY THAT MOVED TO A NAMESPACE A MACHINE READS HAS NOT
# VANISHED, WHICH IS THE SAME CORRECTION ONE PARAGRAPH UP, ONE CARD
# LATER. SAFETY-44's fix made `migrate_unit_keys()` write under the
# ROSTER's first name instead of the hardcoded `inverter-a`, so on a
# renamed roster the before-snapshot holds `unit/inverter-a/
# sign_tripped = True` and the after-snapshot holds
# `unit/garage/sign_tripped = True`. Measured on that build: two
# VANISHED findings for a LATCH THAT IS SITTING ON THE SAME PAGE OF THE
# REPORT, three lines above them, under the name the roster reads.
#
# ⚠️ THE TEST IS NOT "the name appears somewhere". It is: the SAME bare
# key, at the SAME value, under a key in `machine_read` - the set built
# by driving a real start, not by pattern-matching a namespace.
#
# ⚠️ THIS USED TO CITE "M3 (delete the flat rollback view)" AS WHAT
# BOUNDS IT, AND THAT CITATION WAS WRONG IN BOTH HALVES. M3 drops
# `sign_reason` from `LEGACY_UNIT_KEYS` and expects `LATCH KEY NOT UNDER
# A ROSTER NAME`; the delete-the-rollback-view mutant is M9, and every
# key M9 vanishes is FLAT, so it can never satisfy the `unit/` gate
# here. Neither mutation exercises this branch. The sentence "what keeps
# this from being a softening of the check" was not supported by the
# thing it named - and a citation is the worst possible carrier for that,
# because it reads as having been checked.
#
# ⚠️ AND THE REPLACEMENT CITATION WENT STALE THE SAME WAY. It read "the
# branch is now bounded by M12" through a card that DELETED M12 and a
# card that restored it on a different leg - eight lines below the
# paragraph condemning exactly this. Every clause of this comprehension
# is now graded by a row in test_rehearse.py part 5e, one case per
# clause, and each clause-removal must flip exactly its own row. That
# is a property of the section, not a pointer at a named check.
# ⚠️ AND THE DESTINATION MUST BE ONE **THIS TRANSITION MADE**
# (`before.data.get(k) != value`) AND NOT ALREADY SPOKEN FOR
# (`k not in claimed`). Without the first, a roster unit that was
# already latched in its own right is a free suppression slot that
# nobody moved into.
moved_to = sorted(
k for k in after.machine_read
if k.rsplit("/", 1)[-1] == bare and after.data.get(k) == value
and before.data.get(k) != value and k not in claimed)
# ⚠️ AND THE VANISHED KEY'S OWN UNIT MUST BE **OFF THE ROSTER**, WHICH
# THE FIRST VERSION OF THIS RULE DID NOT REQUIRE AND WHICH IS WHAT MADE
# IT A SOFTENING. 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. Measured: a build that DROPS
# `unit/shed/sign_*` while `unit/garage/sign_*` survives, both latched
# by one site event so the reasons match too, reported
#
# none in LEG SAME-STEP: nothing vanished, nothing stranded ...
# NO FINDINGS in the rehearsed transitions
#
# exit 0, on a roster unit's SAFETY-02 latch that had been deleted.
#
# The legitimate case is a RENAME: the key left `inverter-a`, which is
# not on this roster, and arrived under `garage`, which is. A key whose
# own 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.
vanished_unit = key.split("/")[1] if key.startswith("unit/") else None
if vanished_unit is not None and vanished_unit not in after.units \
and moved_to:
claimed.add(moved_to[0])
after.vanished_but_migrated.append(
f"{key} = {value!r} -> {moved_to[0]}")
continue
findings.append(Finding(
"VANISHED STORE KEY",
f"{key} = {value!r} was in the store before the "
@@ -846,6 +919,15 @@ def report_transition(leg_id, name, before, after, findings) -> None:
mark = " " if key in after.data else " (absent, defaulted) "
print(f" {key}{mark}")
if after.vanished_but_migrated:
print()
print(" keys that MOVED to the namespace this roster reads - not "
"lost, and shown")
print(" because a rename that relocates a LATCH is the operator's "
"business:")
for text in after.vanished_but_migrated:
print(f" migrated {text}")
if after.stranded_at_default or after.vanished_at_default:
print()
print(" keys whose value IS the default `Store.load()` supplies, so "