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
@@ -0,0 +1,48 @@
# WHAT THIS BUILD IS SUPPOSED TO ANNOUNCE, recorded so that a change to the
# composition has something to disagree with. THREE COLUMNS:
#
# <entity_id> <unique_id> <device identifier>
#
# ⚠️ ALL THREE, AND THE SECOND AND THIRD ARE THE HALF THE FIRST DRAFT MISSED.
# A reference that carries only entity_ids cannot see the two changes that
# orphan an install without changing a single composed id:
#
# `BASE` renamed (`goodwe_ctl` -> anything) every unique_id changes
# `DEVICE["identifiers"]` changed HA sees a NEW device
#
# Home Assistant's MQTT integration keys its entity registry on `unique_id` and
# its device registry on the device identifier. Change either and HA creates a
# NEW entity beside the old one - and because discovery is published with
# `retain=True` and nothing publishes a removal payload, the OLD config stays on
# the broker and the dead entity stays in the registry, still named by every
# automation that referenced it, never updating again. The composed entity_id is
# identical on both, so an entity_id-only comparison reports a clean transition.
# That is the same shape as the habit-4 this file's own card already caught
# once: a reference that does not carry the thing that moved.
#
# ⚠️ AND `object_id` IS DELIBERATELY NOT A COLUMN. `app/mqtt.py` lines 36-42 and
# 61-76 record that HA 2026.8 does NOT honour `object_id` in a discovery
# payload - the id is composed from device name plus entity name - so renaming
# one is a true no-op and a column for it would make this file refuse a change
# that costs nothing. The check is graded on what HA actually keys on.
#
# PROVENANCE, stated exactly:
# - recorded 2026-09-08 from `release/1.0` at `meter_source: firmware`,
# roster of one, by reading `mqtt.discovery_payloads(units=[])`. It is what
# the code announces.
# - `sensor.goodwe_p1_sample_age` is additionally recorded in `app/mqtt.py` as
# VERIFIED AGAINST THE LIVE SYSTEM on 2026-08-26, and the ESP32's ingest
# guard subscribes to that exact string.
# - ⚠️ IT IS NOT A DUMP OF THE LIVE INSTALL'S ENTITY REGISTRY. That is what
# `--live-entities` is for. This file is about THE BUILD.
#
# Editing this file is how you say "the announcement changed on purpose". Do not
# edit it to make a check pass.
sensor.goodwe_setpoint goodwe_ctl_setpoint goodwe_rs485_controller
sensor.goodwe_grid_power goodwe_ctl_grid goodwe_rs485_controller
sensor.goodwe_battery_power goodwe_ctl_battery goodwe_rs485_controller
sensor.goodwe_battery_soc goodwe_ctl_soc goodwe_rs485_controller
sensor.goodwe_circulating_power goodwe_ctl_circulation goodwe_rs485_controller
sensor.goodwe_maintenance_phase goodwe_ctl_phase goodwe_rs485_controller
sensor.goodwe_controller_status goodwe_ctl_status goodwe_rs485_controller
sensor.goodwe_p1_sample_age goodwe_ctl_p1_age goodwe_rs485_controller
@@ -1,29 +1,34 @@
# The entity ids this add-on ANNOUNCES at the shipped roster of one.
# THE ENTITY IDS THE INSTALL HAS — a stand-in for the owner's real export.
#
# ⚠️ THIS FILE IS EXTERNAL DATA AND THAT IS ITS ENTIRE PURPOSE. The before/after
# entity diff inside `rehearse_roster.py` cannot see a change to the id
# COMPOSITION, because both sides of that diff are composed by the same build:
# rename the MQTT device and every id moves on both sides equally, and the diff
# reports "nothing orphaned" about a change that orphans everything. That is
# `MUTATION.md` habit 4 - a check whose data cannot reach the failure it names -
# and a list that does NOT move with the code is the only thing that closes it.
# ⚠️ THIS FILE AND `announced-reference.txt` ANSWER DIFFERENT QUESTIONS AND ARE
# NOT INTERCHANGEABLE:
#
# PROVENANCE, stated exactly:
# - recorded 2026-09-08 by running this tool against `release/1.0` at
# `meter_source: firmware`, roster of one. It is what the code announces.
# - `sensor.goodwe_p1_sample_age` is additionally recorded in `app/mqtt.py` as
# VERIFIED AGAINST THE LIVE SYSTEM on 2026-08-26, and the ESP32's ingest
# guard subscribes to that exact string.
# - ⚠️ IT IS NOT A DUMP OF THE LIVE INSTALL'S ENTITY REGISTRY. Live HA carries
# roughly 49 `goodwe*` entities (card ARCH-02, read 2026-09-06); the other
# ~41 belong to the ESPHome firmware, to template sensors and to helpers,
# and this add-on announces none of them. THE OWNER STEP in DOCS.md replaces
# this file with the real export, and the tool then separates the ids it
# owns from the ids it does not rather than claiming orphans it cannot
# cause.
# announced-reference.txt what the BUILD announces. Loaded on EVERY run.
# Three columns, because HA keys on `unique_id` and
# on the device identifier as well as on the
# composed entity_id.
# this file (--live-entities)
# what the INSTALL has. One id per line. Orphans
# are graded against it: an id the install has and
# the AFTER shape does not announce is an orphan
# whatever the build's own history says.
#
# Editing this file is how you say "the ids changed on purpose". Do not edit it
# to make a check pass.
# ⚠️ OMITTING `--live-entities` IS A REFUSAL (exit 2), NOT A QUIETER RUN. It
# used to be the other way round - an unreadable file refused, a missing flag
# passed with exit 0 and said nothing about the half it had not checked - and
# the owner runs this ONCE, at commissioning, under time pressure.
# `--no-live-entities` is the deliberate opt-out and it prints a standing
# banner in the report header and again on the exit line.
#
# PROVENANCE: these eight are what `release/1.0` announces at
# `meter_source: firmware`, roster of one, recorded 2026-09-08. They stand in
# for the export so the checks run in CI. ⚠️ THE REAL INSTALL CARRIES ROUGHLY 49
# `goodwe*` ENTITIES (card ARCH-02, read 2026-09-06); the other ~41 belong to
# the ESPHome firmware, to template sensors and to helpers, and this add-on
# announces none of them. The tool separates the ids it owns from the ids it
# does not rather than claiming orphans it cannot cause - which is the half of
# this file the eight-line stand-in cannot exercise, and the reason DOCS.md's
# owner step asks for the real export.
sensor.goodwe_battery_power
sensor.goodwe_battery_soc
sensor.goodwe_circulating_power