ARCH-02 round 2: the pin could not tell one red leg from two, and the tool went
checks / unit (pull_request) Successful in 6m57s

quiet when a flag was forgotten

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
Claude Opus 5
2026-09-08 14:21:34 +02:00
parent 1841416aa4
commit 638c213944
6 changed files with 831 additions and 190 deletions
+100 -27
View File
@@ -1623,37 +1623,99 @@ check, **before** the roster is declared:
Developer tools -> Template:
{{ states | map(attribute='entity_id') | select('search','goodwe') | join('\n') }}
⚠️ **Without it the tool cannot see a change to the id composition at all**,
because both sides of its own before/after diff are composed by the same
build: rename the MQTT device and every id moves equally on both sides, and
the diff reports "nothing orphaned" about a change that orphans everything.
`tools/rehearsal/live-entities-recorded.txt` is the recorded stand-in that
keeps the check live in CI; the real export is what makes it true of *this*
install. The install carries roughly 49 `goodwe*` entities and this add-on
announces eight of them — the tool separates the ids it owns from the ids it
does not, rather than claiming orphans it cannot cause.
⚠️ **Omitting it is a REFUSAL (exit 2), not a quieter run.** The orphan check
is graded against the ids the install actually has, and without that file it
is not graded against reality at all — so the tool will not run rather than
hand back a clean bill of health for a half of the transition it did not
check. If you genuinely have to run without it, pass `--no-live-entities`:
the run then carries a standing banner in the report header **and** on the
exit line saying the orphan check was not graded. The asymmetry used to be
the other way round — an unreadable file refused, a missing flag passed with
exit 0 and no disclosure at all — and the owner runs this once, at
commissioning, under time pressure.
The install carries roughly 49 `goodwe*` entities and this add-on announces
eight of them. The tool separates the ids it owns from the ids it does not,
rather than claiming orphans it cannot cause.
### The reference, which is loaded on every run
`--reference` defaults to `tools/rehearsal/announced-reference.txt` and is read
**whether or not** you pass an entity list. It records what the build is
supposed to announce, three columns per row:
<entity_id> <unique_id> <device identifier>
⚠️ **All three, and the second and third are the ones an entity_id-only list
cannot see.** The tool's own before/after diff is composed twice by the same
build, so a change to the composition moves both sides equally and the diff
reports nothing. Worse, two changes orphan an install *without moving a single
composed entity_id*: renaming `mqtt.BASE` (every `unique_id` changes) and
changing `DEVICE["identifiers"]` (HA sees a new device). Home Assistant keys its
entity registry on `unique_id` and its device registry on the identifier, so
either one creates a **new** entity beside the old — and because discovery is
published `retain=True` with no removal payload, the old config stays on the
broker and the old entity stays in the registry, still named by every automation
that referenced it, never updating again. Both were measured coming back
**exit 0** before the reference carried three columns.
`object_id` is deliberately **not** a column: `app/mqtt.py` records that HA
2026.8 does not honour it in a discovery payload, so renaming one is a true
no-op, and `test_rehearse.py` requires that mutation to **survive** — otherwise
"the reference notices any edit to `mqtt.py`" would be indistinguishable from
"the reference notices what HA keys on".
⚠️ **No result in this repository was produced against real state.** Everything
recorded here and in `test_rehearse.py` was measured on the synthetic fixtures
in `tools/rehearsal/`.
### What it found on the synthetic fixtures
### What it confirms on the synthetic fixtures
⚠️ **A pre-DEV-02 flat store that declares a multi-inverter roster comes up with
its SAFETY-02 latch DISARMED, unless the first roster entry happens to be named
`inverter-a`.** `Controller.__init__` calls `migrate_unit_keys(store, UNIT_A)`
with a hardcoded constant, so the latch lands in `unit/inverter-a/sign_tripped`;
a roster of `["garage", "shed"]` reads `unit/garage/sign_tripped`, which is
absent. And `_grade_store_migration()` — the guard written for exactly this —
sees the migration's own misdirected key satisfy its `namespaced_latched` test
and returns without re-arming. The file says `sign_tripped: true`, the banner is
green, and nothing is held at 0 W. **This is SAFETY-44, and the answer to it is
that the asymmetry is not safe on that path.** It is reported here and pinned by
`test_rehearse.py` part 5; the fix belongs to its own card, shown red first.
⚠️ **This is a SECOND INSTRUMENT reaching a conclusion this repository already
holds, not a discovery.** `test_maintenance.py` section 13 — *"RECORDED, NOT
FIXED: SAFETY-02's migration has the same wrong-namespace hole, and it is a
LATCH"* — already states it, already names `namespaced_latched`, and already
carries the sentence *"the guard is defeated by the very key the defect wrote"*.
That file is on `release/1.0` and this card does not touch it. What is new here
is that the finding is now reachable **from a state file the owner supplies**
rather than only from a fixture built in a test, and that it is reached by a
different route: a full `Controller` start diffed against its predecessor,
rather than assertions over a `MemStore`. Two instruments, one answer.
The same end state is reached whether the roster is declared in the same step as
the upgrade or a restart later — in the staged case because the flat start
stamps `schema_version` and the roster start's migration then returns early.
**The finding.** A pre-DEV-02 flat store that declares a multi-inverter roster
comes up with its SAFETY-02 latch **DISARMED**, unless the first roster entry
happens to be named `inverter-a`. `Controller.__init__` calls
`migrate_unit_keys(store, UNIT_A)` with a hardcoded constant, so the latch lands
in `unit/inverter-a/sign_tripped`; a roster of `["garage", "shed"]` reads
`unit/garage/sign_tripped`, which is absent. And `_grade_store_migration()` —
the guard written for exactly this — sees the migration's own misdirected key
satisfy its `namespaced_latched` test and returns without re-arming. The file
says `sign_tripped: true`, the banner is green, and nothing is held at 0 W.
**Both routes reach it, and they are NOT the same defect.** The rehearsal
reports them as separate legs because they need separate fixes:
| leg | how it gets there |
|---|---|
| `SAME-STEP` | the roster is declared in the same step as the upgrade. The migration runs, and is handed `UNIT_A`. |
| `STAGED` | the flat config runs once under 0.7.0+ first. That start stamps `schema_version = 1`, so the roster start's migration takes its `from_version >= SCHEMA_VERSION` early return and **the latch never moves at all**. |
⚠️ **So "hand `migrate_unit_keys()` the roster's own first name" is NOT the
whole fix, and the rehearsal measures that rather than asserting it.**
`test_rehearse.py` part 5b builds exactly that change and reports:
LEG STAGED: DISARMED
LEG SAME-STEP: clean
The staged path never reaches the line that was changed. A fix that stops there
closes the half an operator is least likely to take and leaves the half that
"upgrade first, declare the roster later" produces. The guard side —
`namespaced_latched` counting a namespace that is not on the roster — is what
covers both, and a change there turns every SAFETY-44 check in part 5 red, which
is what a fix should do to a pin that specifies a defect.
The fix belongs to its own card, shown red first. Part 5 pins the current
behaviour as a requirement so it cannot drift green by accident.
### The reverse direction: the retreat is supported, the round trip is not
@@ -1666,9 +1728,20 @@ is supported and does not corrupt state.** Measured on the fixtures:
so a single-FSM add-on finds a current history rather than one dated the day
the roster was declared;
* **it orphans eight Home Assistant entities** — the four per-unit rows for each
of the two units. Their history stops and any automation naming them stops
firing, silently. That is the price of the retreat and it is not recoverable
by advancing again: HA keeps the id it composed at first discovery.
of the two units. **Measured:** the add-on stops announcing them and stops
publishing their state, so their history gaps and any automation whose
trigger names them stops firing, silently and without an error.
⚠️ **What is NOT measured, and was previously overstated here:** whether
advancing again restores them. An earlier draft of this section said the
orphaning was "not recoverable by advancing again: HA keeps the id it composed
at first discovery" — that reasoning cuts the other way, since keeping the
composed id is precisely what would let a re-advertised entity land back on
it, and discovery is published `retain=True` with no removal payload. Nothing
in this repository has tested a re-advance against a real Home Assistant, so
the claim is withdrawn rather than restated in the other direction. Plan the
retreat around the measured part: **the eight rows stop updating for as long
as you are retreated.**
⚠️ **What is NOT supported is the round trip.** A maintenance cycle that
completes *while the install is rolled back* is **discarded** when it