ARCH-02: rehearse the flat-to-roster switch, and it comes up disarmed

Nothing had ever run the real `Controller` through the transition inverter B's
commissioning performs on 2026-09-12. `tools/rehearse_roster.py` does, over a
COPY of a state file given as an input path, and reports the difference: every
store key that moved, appeared, vanished or was STRANDED under a name no
machine reads; every entity id before and after with orphans named; and whether
the maintenance history MAINT-05 moved once survived being moved again.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
Claude Opus 5
2026-09-08 13:15:05 +02:00
parent 37dd0f00cc
commit e06906d65f
10 changed files with 1543 additions and 0 deletions
@@ -0,0 +1,22 @@
# ARCH-02 fixture: THE SHAPE THE LIVE BOX IS IN TODAY (a flat, single-inverter
# config). ⚠️ ILLUSTRATIVE ENTITY NAMES, NOT THE LIVE ONES - the owner runs the
# rehearsal against a copy of the real options blob, and this file exists so
# that the harness has a synthetic transition it can be graded on.
setpoint_entity: number.goodwe_setpoint
soc_entity: sensor.goodwe_battery_soc_pct
batt_entity: sensor.goodwe_battery_power_w
batt_invert: false
max_w: 5000
bms_soc_floor: 10
meter_source: 'off'
meter_entity: sensor.p1_active_power
inverters: []
gain: 0.6
slew_w: 0
deadband_w: 15
target_grid_w: 0
step_w: 10
heartbeat_s: 10
stale_input_s: 15
maintenance_discharge_w: 2000
maintenance_charge_w: 2000
@@ -0,0 +1,34 @@
# The entity ids this add-on ANNOUNCES at the shipped roster of one.
#
# ⚠️ 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.
#
# 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.
#
# Editing this file is how you say "the ids changed on purpose". Do not edit it
# to make a check pass.
sensor.goodwe_battery_power
sensor.goodwe_battery_soc
sensor.goodwe_circulating_power
sensor.goodwe_controller_status
sensor.goodwe_grid_power
sensor.goodwe_maintenance_phase
sensor.goodwe_p1_sample_age
sensor.goodwe_setpoint
@@ -0,0 +1,42 @@
# ARCH-02 fixture: THE SHAPE AFTER INVERTER B IS COMMISSIONED.
#
# ⚠️ THE FIRST ROSTER ENTRY IS **NOT** CALLED `inverter-a`, AND THAT IS THE
# WHOLE FIXTURE. `MUTATION.md` habit 4's seventh instance is MAINT-05 shipping
# `migrate_maint_keys(store, UNIT_A, ...)` under a comment saying it must not:
# every two-pack fixture in the suite named its first entry `inverter-a`, so
# `UNIT_A` and `inverters[0].name` were the same string in every check that
# existed and the defect was invisible. Names that are not `inverter-a` are what
# make a misdirected namespace something a rehearsal can SEE.
#
# `roster-inverter-a.yaml` beside this file is the same roster with the first
# entry named `inverter-a`, so the two can be compared - see DOCS.md.
setpoint_entity: number.goodwe_setpoint
soc_entity: sensor.goodwe_battery_soc_pct
batt_entity: sensor.goodwe_battery_power_w
batt_invert: false
max_w: 5000
bms_soc_floor: 10
meter_source: 'off'
meter_entity: sensor.p1_active_power
gain: 0.6
slew_w: 0
deadband_w: 15
target_grid_w: 0
step_w: 10
heartbeat_s: 10
stale_input_s: 15
maintenance_discharge_w: 2000
maintenance_charge_w: 2000
inverters:
- name: inverter-a
setpoint_entity: number.goodwe_setpoint
soc_entity: sensor.goodwe_battery_soc_pct
batt_entity: sensor.goodwe_battery_power_w
max_w: 5000
bms_soc_floor: 10
- name: inverter-b
setpoint_entity: number.goodwe_b_setpoint
soc_entity: sensor.goodwe_b_battery_soc_pct
batt_entity: sensor.goodwe_b_battery_power_w
max_w: 4600
bms_soc_floor: 10
@@ -0,0 +1,42 @@
# ARCH-02 fixture: THE SHAPE AFTER INVERTER B IS COMMISSIONED.
#
# ⚠️ THE FIRST ROSTER ENTRY IS **NOT** CALLED `inverter-a`, AND THAT IS THE
# WHOLE FIXTURE. `MUTATION.md` habit 4's seventh instance is MAINT-05 shipping
# `migrate_maint_keys(store, UNIT_A, ...)` under a comment saying it must not:
# every two-pack fixture in the suite named its first entry `inverter-a`, so
# `UNIT_A` and `inverters[0].name` were the same string in every check that
# existed and the defect was invisible. Names that are not `inverter-a` are what
# make a misdirected namespace something a rehearsal can SEE.
#
# `roster-inverter-a.yaml` beside this file is the same roster with the first
# entry named `inverter-a`, so the two can be compared - see DOCS.md.
setpoint_entity: number.goodwe_setpoint
soc_entity: sensor.goodwe_battery_soc_pct
batt_entity: sensor.goodwe_battery_power_w
batt_invert: false
max_w: 5000
bms_soc_floor: 10
meter_source: 'off'
meter_entity: sensor.p1_active_power
gain: 0.6
slew_w: 0
deadband_w: 15
target_grid_w: 0
step_w: 10
heartbeat_s: 10
stale_input_s: 15
maintenance_discharge_w: 2000
maintenance_charge_w: 2000
inverters:
- name: garage
setpoint_entity: number.goodwe_setpoint
soc_entity: sensor.goodwe_battery_soc_pct
batt_entity: sensor.goodwe_battery_power_w
max_w: 5000
bms_soc_floor: 10
- name: shed
setpoint_entity: number.goodwe_b_setpoint
soc_entity: sensor.goodwe_b_battery_soc_pct
batt_entity: sensor.goodwe_b_battery_power_w
max_w: 4600
bms_soc_floor: 10
@@ -0,0 +1,11 @@
{
"_comment": "ARCH-02 SYNTHETIC FIXTURE - a store that has ALREADY run the flat config under 0.7.0+, so DEV-02's `schema_version` is stamped and the SAFETY-02 latch is not tripped. NOT a copy of the live box's /data/state.json. This is the CLEAN CONTROL for the MAINT-05 half of the rehearsal: the flat->roster transition over it must come out with NO findings, which is what makes each deliberate mutation's RED mean something. A harness that is red on every input has not measured anything.",
"schema_version": 1,
"phase": "idle",
"phase_started": "2026-08-07T10:00:00+00:00",
"last_completed": "2026-08-07T18:42:00+00:00",
"last_start_attempt": "2026-08-07T10:00:00+00:00",
"sign_tripped": false,
"sign_reason": "",
"auto": true
}
@@ -0,0 +1,10 @@
{
"_comment": "ARCH-02 SYNTHETIC FIXTURE - a PRE-DEV-02 flat state file. NOT a copy of the live box's /data/state.json; this session cannot read that one and did not try. It is built to carry, deliberately, the two shapes that state built by the happy path does not have: a LATCHED SAFETY-02 failsafe under the flat spelling, and a real maintenance history under the flat spelling. Both are LEGACY-SHAPED: there is no schema_version and no maint_schema_version, so this file was last written by a controller that had never heard of unit namespaces. Losing either one is silent.",
"phase": "idle",
"phase_started": "2026-08-07T10:00:00+00:00",
"last_completed": "2026-08-07T18:42:00+00:00",
"last_start_attempt": "2026-08-07T10:00:00+00:00",
"sign_tripped": true,
"sign_reason": "SIGN_DISAGREEMENT: commanded -1800 W against a meter reading -1450 W for 190 s",
"auto": true
}