Commit Graph
70 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5.5 43a52e7f60 SAFETY-84: maintenance charge at N=2 no longer stalls
checks / unit (pull_request) Successful in 23m57s
infra-pin / fresh (pull_request) Successful in 2s
At N>=2 a cheap-window maintenance charge files a site `hi` (`charge`,
P_MAINT_SHAPE) equal to what the charging pack's own `hi` places, so the
other pack never has to give against it. The charge is sized to the
quarter's headroom under the peak aim so it yields to the peak guard, and
to 0 W under a site safety stop. N=1 byte-identical (safety84_identity).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:47:15 +02:00
glenn schrooyenandClaude Opus 5.5 79b94024cb Merge release/1.0 (OPT-11 P4, #156) into safety-83-peak-over-drain
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:35:28 +02:00
adminandClaude Opus 5.5 d77f1b1fdf SAFETY-83: peak guard outranks the maintenance drain at N=1
checks / unit (pull_request) Successful in 16m54s
infra-pin / fresh (pull_request) Successful in 1s
At N=1 the drain is the site SET at P_MAINTENANCE (60), so the peak floor
(P_PEAK, 50) was not applied and the house imported what the drain left.
Where the floor asks for more than the drain rate, main.py now refiles the
peak claim at P_MAINTENANCE, so it wins; safety SETs still park everything.
Nothing else changes: safety78_identity --diff against 35cdffe changes only
cycles where the guard wanted more than the drain; N=2 is byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:40:14 +02:00
glenn schrooyenandClaude Opus 5.5 b171ada894 Merge release/1.0 (SAFETY-78, #154) into opt11-p4-solar-forecast
checks / unit (pull_request) Successful in 23m54s
infra-pin / fresh (pull_request) Successful in 1s
Conflicts: modetrade.decide() takes both the day plan (targets) and
SAFETY-78's draining flag; the Why line keeps SAFETY-78's other_cycle
ahead of the plan's word (test added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:37:09 +02:00
glenn schrooyenandClaude Opus 5.5 a692560960 Merge release/1.0 (SAFETY-81, #155) into opt11-p4-solar-forecast
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:08:52 +02:00
glenn schrooyenandClaude Opus 5.5 fbd884797d OPT-11 P4 review: Cheap hours ignores the plan; midnight-stale forecast; stricter ids
- Cheap hours only fills to the ceiling with or without a forecast, as the
  design (section 1) says. Skipping the sun's share cost 0.31 EUR on real
  2026 prices (29.72 against P3's 29.41) and saved nothing on the P3 set.
- A forecast reading from before local midnight is stale: until the
  integration refreshes, "left today" is still yesterday's 0 and
  "tomorrow" is really today.
- Forecast entity ids must be sensor.[a-z0-9_]+ (they go into the
  /api/states/<id> path).
- House profile: a stored day that is not a date, or is after today, is
  dropped instead of kept forever.
- Text: the forecast line says "for the rest of today"; the learning line
  no longer promises "tomorrow" (a plan needs one full day).
- Tests for four surviving review mutants (hour sample floor, the dear run
  after the cheap run, share by room, daytime use) and the fixes above.
- Replay tool: `--fc zero` (a forecast of no sun).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:08:47 +02:00
goodwe-review 516bc8b4ec Merge release/1.0 into safety-78-drain-at-two
checks / unit (pull_request) Successful in 18m49s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:53:47 +02:00
adminandClaude Opus 5.5 f967073cfe SAFETY-81 review: 10 s hold, ERROR line per episode, more scenarios
checks / unit (pull_request) Successful in 20m31s
infra-pin / fresh (pull_request) Successful in 2s
- The alarm must hold for peakguard.ALARM_HOLD_S (10 s) on the projection's
  clock. A load that tops cap + battery for ~5 s every 30 s alarmed at every
  quarter start while the quarter landed under the aim (sine4500_fast).
- A cycle with no battery reading leaves the hold alone.
- Notified once per quarter, but the ERROR line is written per episode.
- New sim scenarios (sine4500_fast, twice7000, step6500_gap) with 5a4cdc2
  digests; control still byte-identical. Unit checks for export and the
  hold across a missing reading. test_peakguard's saturated fixture moves
  the hold start back instead of waiting it out.
- DECISIONS and DOCS updated: costs of the hold, tolerance reasoning, the
  double count in control left to a follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:58:37 +02:00
goodwe-agent 6eb67c0f9c SAFETY-81: no false 'Load too high' alarms
checks / unit (pull_request) Successful in 16m11s
infra-pin / fresh (pull_request) Successful in 1s
The alarm now also needs peakguard.load_too_high(): with the battery at
full power, the quarter still ends more than 10 W over the real limit.
The battery reading is the lower of this cycle's and the last, so a meter
sample from before the battery's last increase is not counted twice.
At most one alarm per quarter. Control outputs unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:55:38 +02:00
Claude 5d27f8496f SAFETY-78: maintenance drain at N=2 no longer stalls
checks / unit (pull_request) Failing after 17m13s
infra-pin / fresh (pull_request) Successful in 2s
At two inverters the drain pins its pack and the site target is split
around the pin. Below the pin the other pack had to charge, the allocator
refused the opposite directions, and the site sat at 0 W with an ARBITER
line every cycle (house below or above the drain rate, or a grid charge).

main.py now files a site limit `drain` (lo = what the pin places, at
P_MAINTENANCE) at N>=2 while a pack drains, so the other pack only adds
power in the same direction. A drain that cannot give power waits with
one log line. With battery modes on, no grid charge while a pack drains,
and the other battery's card says why. N=1 is byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:52:51 +02:00
Claude eb10537aee OPT-11 P4: solar forecast and day plan
A Home Assistant solar forecast (Forecast.Solar, Solcast or Open-Meteo
shapes) and the add-on's own hourly house use decide, before each cheap
run, whether the sun will fill each battery before the expensive hours.
Leftover solar then buys from the grid only the shortfall; Cheap hours only
skips the part the sun covers; Solar first never grid-charges. No, stale or
failed forecast: P3 option A exactly (12 digests pinned from 5a4cdc2).

Replay (16 recorded days, forecast errors from Elia's day-ahead BE solar):
year-earlier prices P4 19.15 vs P2 19.33 / option A 19.44 / old trade 18.14;
real 2026 prices P4 29.19 vs 32.26 / 32.01 / 31.40 EUR net.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:29:32 +02:00
Claude dev agent 902524ae5e OPT-11 P3: price runs per battery
checks / unit (pull_request) Successful in 13m45s
infra-pin / fresh (pull_request) Successful in 1s
Cheap and expensive hours as runs of consecutive firm price slots
(priceruns.py); each battery's role per tick from its mode (modetrade.py):
grid charging for Leftover solar / Cheap hours only, never Solar first;
holding for the expensive runs; selling per battery (pack_sell) above the
spread and the floor. The site trade is sized inside the modes (loop bound)
and the peak guard's headroom; a run edge reseeds the loop. Cheap hours only
and selling are enabled on the Batteries screen and in the HA select
(appended). The site Why row no longer says "held back" when the peak limit
pushes power up (F2). Modes off: byte-identical to 7a00f12 on the trade path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 02:37:44 +02:00
Review AgentandClaude Opus 5.5 ce0aea30b6 SAFETY-79 review: margin under a banked month peak too; identity harness reaches headroom and maintenance
checks / unit (pull_request) Successful in 11m50s
infra-pin / fresh (pull_request) Successful in 1s
- quarter_target_w() is now the margin under quarter_ceiling_w(): a peak
  banked between cap - margin and the cap was aimed at exactly, so one
  overshoot crossed the cap; above the cap the same overshoot raised the bill.
- safety79_identity.py hashes charge_headroom_w() and the shaping suspension,
  and adds n1_maint/n2_maint (cheap window, forecast across cap - 50 .. cap).
  Digests re-printed on ed739d4 and pinned; margin 0 still identical.
- test_margin_under_banked_peak; DECISIONS and DOCS updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:49:16 +02:00
glenn schrooyen 0951cb227f Merge release/1.0 into safety-79-peak-margin
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 11m36s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	goodwe_controller/app/uilayout.py
2026-09-28 23:56:33 +02:00
goodwe-review-agent a9f7d78527 UX-13 P3 review: truthful peak lines, a clock-proof test, Simple stays default
checks / unit (pull_request) Successful in 11m10s
infra-pin / fresh (pull_request) Successful in 2s
- The peak limit's Why and plan block need a real power request (floor above
  0 W), not a blind or floor-limited guard. An Off battery gets the peak
  block only while it gives power. Why no longer says every battery gives.
- test_batteries_screen pins the fixture clock: unpinned it failed every
  morning. New checks cover the trade and cycle filters, the blind guard and
  an Off battery that A covers alone.
- Settings keeps only a picked view; a link to Installer is not remembered.
- Browser check: badges, unsaved change across views, locked value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:20:33 +02:00
glenn schrooyenandClaude Opus 5.5 7c4c8f5c66 SAFETY-79: peak guard keeps a margin below the limit
checks / unit (pull_request) Failing after 11m34s
infra-pin / fresh (pull_request) Successful in 1s
New option peak_margin_w (default 50 W, 0-500 W). The peak guard, the
forecast checks and the maintenance charge sizing aim at
peak_cap_w - peak_margin_w; the month peak, the bill, headroom, warnings
and every notice keep the real cap. Owner decision 2026-09-28.

peak_margin_w 0 is byte-identical to ed739d4 (tools/safety79_identity.py,
test_peak_margin.py part 1). validate.py rule 12 refuses a value outside
0-500, not whole, or not below the peak limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
2026-09-28 23:09:39 +02:00
Claude 2d2b6afb07 UX-13 P3: Simple view and Batteries screen
checks / unit (pull_request) Successful in 11m9s
infra-pin / fresh (pull_request) Successful in 2s
- Batteries: one card per battery with On/Off, the mode list (cheap hours
  and selling greyed out until OPT-11 P3), level, and Now / Today / Why
  lines built in homeview.py. Replaces the OPT-11 P1 minimal cards and uses
  the existing pack_use / pack_mode actions and store keys.
- An Off battery the peak limit still needs says so with its figure; no line
  shows 0 W beside moving power (OPT-11 P1 test F2). Today says "Not planned
  yet" until OPT-11 P3/P4.
- Home's plan leaves Off batteries out, except for the peak limit.
- Settings opens on Simple: the layout's simple fields with an applies chip
  and a note, plus the battery switches and modes. Installer is one tap.
- English and Dutch words in app/ui_text; test_batteries_screen.py; browser
  check tools/ux13_browser_check.py (CHECK=1 ux13_screenshots.sh); new and
  changed screenshots only.

No control, allocator, arbiter or command module changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
2026-09-28 22:39:43 +02:00
glenn schrooyenandClaude Opus 5.5 ffc9a4d721 OPT-11 P1 review: a mode never beats the peak floor; SAFETY-03 kept at the rail
checks / unit (pull_request) Successful in 11m4s
infra-pin / fresh (pull_request) Successful in 2s
- arbiter: a mode now gives way only as far as a site limit above P_MODE
  needs (_unit_modes at N>1, _site_mode at N=1). Before, the modes were
  released only when the peak floor bound S, so a trade won above the floor
  kept them and placed less than the floor (an Off battery at N=1 went to 0 W
  under a 2000 W floor), and a capped grid charge released an Off battery.
  While released, held batteries weigh 0 and carry only the shortfall.
- control: a side of the loop bound that reaches the rail is left to the
  rail, so SAFETY-03's i_clamped still fires with every battery on.
- opt11_identity: add maintenance scenarios and a 6000-case arbiter digest
  (peak floor, trades, maintenance, refusals); a5dddad digests pinned.
- tests: test_arbiter part 11, test_control (g), test_modes (0/1 refused,
  loop bound counts live units only). DOCS and DECISIONS say the new rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
2026-09-28 19:41:36 +02:00
Claude dev agent d69fd8fbfd OPT-11 P1: per-battery mode limits, on/off, solar order
checks / unit (pull_request) Successful in 10m59s
infra-pin / fresh (pull_request) Successful in 1s
A battery's mode is a soft unit limit (source `mode`, P_MODE 35) kept
apart from the hard limits in pre_resolve(). The allocator applies it
only while the site target is decided at or below 35 (new
Resolution.deciding_priority), so the peak floor releases it and a mode
`hi` never meets the site `lo`. At N=1 resolve() applies the same rule
to the site target after stage 2. A mode range without 0 W is dropped
as a software fault, so REFUSE_SIGN stays unreachable from a mode.

compute() gets the loop bound (sum over live units of hard limits with
modes applied), so the loop never asks for watts no battery may give
and the saturation freeze never parks on them. While charging, Leftover
solar weighs 0 so Solar first fills first. Store keys pack_use and
pack_mode per battery, set by two new /action values; Off stops and
skips maintenance with a log line. Minimal Batteries cards (EN/NL).

Off by default (battery_modes_enabled); off, commands are byte-identical
to a5dddad at N=1 and N=2 (test_modes.py part 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:51:29 +02:00
Claude dev agent 667b8e9145 UX-13 P5: Dutch text, STYLE check, DOCS screenshots, period editor
checks / unit (pull_request) Successful in 10m45s
infra-pin / fresh (pull_request) Successful in 1s
- app/ui_text/nl.json and app/uitext.py: every page word in Dutch, English
  fallback per key; the page picks by browser language. Home's sentences
  move into ui_text `home_words`, so /status?lang=nl builds them in Dutch.
- test_ui_text.py: EN/NL key and placeholder parity, STYLE limits, jargon
  lists for both languages, no inline details, untranslated Dutch caught.
  Shared rules moved unchanged to tools/style_rules.py.
- tools/ux13_screenshots.sh/.py: DOCS screenshots from the demo fixtures in
  headless Chromium; docs/img/ux13/. Battery modes (P3): no screenshot.
- "Other time windows": a list editor (ui.periods) that builds
  static_profile and names bad times, empty prices, overlaps and gaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:33:32 +02:00
adminandClaude Opus 5.5 159d7461d8 UX-15: setup-only mode, and the wizard fixes F1, F2, meter, step 1
A new install names no inverter. When that is the startup gate's only
refusal, the add-on now serves the setup wizard in setup-only mode instead
of exiting 78. It builds no controller, MQTT client or meter task, its
HomeAssistant is read-only (every service call refuses before sending),
Controller refuses a read-only HomeAssistant, and /action answers 409.
Any other refusal still exits 78. DECISIONS/UX-15-setup-only-mode.md.

Wizard fixes from the UX-13 P4 test:
- F1: /config/validate converts what it can, so a conversion error blocks
  its own step and the live checks still run; save 422 carries steps.
- F2: saves send the model the page opened (base); only keys this page
  changed are written onto the blob stored now.
- A meter entity missing from Home Assistant is red on the last step.
- Step 1 says the reading went down after a skip that followed a fall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:48:21 +02:00
review-agent 3198f346fd UX-13 P4 review fixes (PR #146, comment 1887)
checks / unit (pull_request) Successful in 10m24s
infra-pin / fresh (pull_request) Successful in 1s
R1: first_start also needs a Store created at this boot (Store.fresh), so
    old never-started sites (both TEST-23 add-ons, one in shadow) stay on Home.
R2: a kettle skip after a fall says on the last step that the reading went down.
R3: the shadow switch goes only on the model the wizard posts, never into V.
R4: Home Assistant not answering is red on the last step.
R5: meter + solar + battery below -300 W flags a reversed battery sensor.
R6: the live checks say what to change and where.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 06:12:09 +02:00
adminandClaude Opus 5.5 be57b87c34 UX-13 P4: the setup wizard for installers
checks / unit (pull_request) Successful in 10m22s
infra-pin / fresh (pull_request) Successful in 1s
The Setup tab draws the Installer view's page model one step at a time,
from each field's `wizard` step in app/uilayout.py: meter (with the kettle
test), inverters and batteries, solar (optional), prices, peak limit
(battery modes: "comes in a later update", OPT-11), check and start.
It reuses P2's controls and saves through /config/validate and
/config/save. No route was added.

- First run: /config/model says `setup_needed` when the stored options name
  no power request entity, or on a new install (no `setup_done` and no
  `auto` in the Store, control not started). The page opens #setup only
  when opened with no route. The wizard's save writes `setup_done`.
- Kettle test: GET ha/entities?entity= reads one entity now, in W. The
  change decides: up keeps meter_invert off, down turns it on; a net or
  import/export meter that goes down blocks the step with what to check.
- Last step: validate.setup_checks() beside the refusals: the meter reads,
  each power request entity exists in HA (bad if not, SAFETY-77), battery
  levels and power with direction, solar sign, prices. Saves with
  shadow_mode on by default, then a confirmed restart naming the 0 W.
- P2 test finding (a): a stale save lists only the keys the other surface
  moved (the blob handed out on open, by fingerprint, against stored now).
- test_setup.py; tools/ux13_wizard_blobs.py makes the 88 blobs the wizard
  can save; all accepted by Supervisor 2026.08.0 AppOptions (offline).
- DECISIONS/UX-13-setup-wizard.md; DOCS "Setup, for a new install".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-28 05:31:18 +02:00
UX-13 P2 review 7be09197d4 UX-13 P2 review fixes (PR #145, comment 1880)
checks / unit (pull_request) Successful in 10m15s
infra-pin / fresh (pull_request) Successful in 2s
1. Discard reloads /config/model: after a stale save the page kept the
   new fingerprint with the old values, so Discard + Save overwrote the
   other surface's change without a 409.
2. Removing cards back to one restores the stored inverter shape, so a
   flat site stays flat after add + remove.
3. A choice the page can only keep ("Other time windows") stays offered
   while it is the stored value.
4. A load locks every locked value again.
5. at_zero() needs an accepted 0 W write; before the first one Home says
   "Going to 0 W" (the heartbeat writes every heartbeat_s, so it clears).
6. A field with a unit drops the translation's "(W)" from its label: the
   peak limit read "Peak limit (W)" beside a kW value.
7. A mirrored choice lists the mirrored field's keys, so a search for
   static_profile finds the contract on a fixed-price site.
8. test_settings: a save refused by ui_to_options writes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 03:43:47 +02:00
Claude c9ab48d6fc UX-13 P2: the Installer settings view
- Settings tab: the Installer view (app/static/settings.js), drawn from
  app/uilayout.py through the new app/uipage.py: search, six collapsible
  groups with "n to fix / n to check / n changed" badges and an "applies
  after a restart" chip, radio cards, segmented choices, sliders with units
  (the peak limit in kW), entity pickers with search and the live reading,
  inverter cards, solar rows, locked measured values behind "Change", and
  the add-on's refusals, warnings and info beside the field they name.
- Save path: the page posts its model to the existing config/validate and
  config/save; uimodel turns it into options before the shipped validator,
  so nothing it refuses is saved. No route added. A value equal to the
  stored one is written as stored, so a save that changed nothing writes an
  identical blob. Save bar, then the restart dialog that names 0 W.
- The region choice shows its preset year; Wallonia and Brussels are listed
  as having no preset yet (TARIFF-22). "Classic settings" keeps /config.
- P1 test findings: (a) "Going to 0 W" until the last accepted power request
  is 0 W; (b) the trade blocks and the price line name the feed's own points
  ("Lowest published price"), the hour bars say "hour average"; (c) the entity
  list's WARNING gives a reason on a timeout.
- test_settings.py; test_home, test_haentities, test_uilayout extended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-28 02:49:58 +02:00
Claude f8d63cc614 UX-13 P1: page shell and Home screen on a structured /status
checks / unit (pull_request) Successful in 9m37s
infra-pin / fresh (pull_request) Successful in 2s
- app/static/{index.html,app.css,app.js}: tabs (Home, Batteries, Settings,
  Setup), hash routes, phone bottom tabs, light/dark. Served inlined at "/";
  the route list is unchanged, and every URL is relative.
- app/homeview.py: /status gains state, now, batteries, plan, attention,
  month_peak and control beside the old fields. The plan is what the add-on
  does today: the arbitrage verdict (the same plan.arbitrage() call as the
  cycle), the monthly cycle, the peak limit, "follows the house".
- The Home plan chart has hover, tap and keyboard-focus tooltips: price bar =
  hour and price; block = what, when, why, kWh and EUR.
- Region preset and year: in config/model (for P2) and on the Home price line.
- test_home.py; test_messages part 8 scans every Home sentence; STYLE plan line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-28 00:45:01 +02:00
glenn schrooyenandClaude Opus 5.5 15627c38a6 UX-13 P0: layout table, page<->options derivation, warning tier, entity list
checks / unit (pull_request) Successful in 9m32s
infra-pin / fresh (pull_request) Successful in 1s
The foundation for the new settings page. No visible UI change.

- app/uilayout.py: screen -> group -> part -> field for every option in
  config.yaml (67 fields, 11 derived, 0 hidden), with control, unit, display
  scale, step, entity kind, show conditions, Simple/wizard placement and
  "applies after a restart". Ranges stay in the schema, words in
  translations and the new app/ui_text/en.json.
- app/uimodel.py: options_to_ui()/ui_to_options() for the derived keys (one
  inverter vs the list, solar rows, static_profile from simple prices, a
  year-stamped region preset for the levies figure, and an OPT-11 hook for
  arbitrage_enabled that changes nothing yet). Unchanged derivations write
  the stored values back, so the round trip is exact.
- validate.py: validate.Note, a warn/info tier. Each logged warning is also
  a note when a list is passed; config_notes() adds checks that need live
  data (missing entity, a kW sensor read as W, a limit below this month's
  peak). /config/validate returns them as "notes". No refusal changed: 261
  recorded validator calls, 0 differ (tools/ux13_validator_calls.py).
- GET ha/entities: Home Assistant's entity list for the picker, read through
  hass.states(), cached 30 s, 10 s timeout. When HA is down it answers 200
  with the old list marked old, or an empty list that says why.
- DECISIONS/UX-13-one-layout-table.md amends UX-02's "no option key in UI
  code": keys may appear in uilayout.py and uimodel.py only, both tested.
- Tests: test_uilayout, test_uimodel (round trip over the recorded calls,
  the TEST-23 option files and config.yaml defaults), test_haentities, a
  warning-tier part in test_validate; test_messages scans notes and
  ui_text; test_pv and test_selfconfig part 7 widened on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 23:11:31 +02:00
adminandClaude Opus 5.5 3c0f2b9669 SAFETY-52 review: a refusal is under 100 W, and any delivery ends it
checks / unit (pull_request) Successful in 9m22s
infra-pin / fresh (pull_request) Successful in 1s
- Engage (and seed from a stale stop) below REFUSED_BELOW_W, the probe's
  own 100 W answer, not below saturation_w: a battery derated to 100-499 W
  was held at 0 W ~93 % of the time.
- A reading of 100 W or more in the held direction ends the refusal, probe
  or not: at a 1 s meter SETTLE_CYCLES is 12 s, shorter than a healthy
  reading's lag, and that held a healthy step at 0 W until the next probe.
- test_refusal part 5b and a seed test; DOCS states the N>1 limit; the
  DECISIONS record carries the traced cause of the replay's extra trips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:11:42 +02:00
glenn schrooyenandClaude Opus 5.5 1f97c7652f SAFETY-52: a battery that refuses is asked for 0 W, with a 200 W probe every 15 min
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Failing after 9m32s
At the BMS cut-out the saturation freeze held the last request for hours
(+5000 W for 11.87 h on 09-06, +1940 W for 3 h 55 m on 09-19) until the grid
error changed sign. compute() now carries a refused direction: after
SETTLE_CYCLES of a ~0 W reading against a larger ask it holds that direction
at 0 W, probes 200 W (below the gate's 250 W) every 15 minutes, and lets go
on charge, an answered probe, stop/start or another writer. A stale-reading
trip on a frozen ~0 W reading seeds the same hold, which ends SAFETY-59's
~70 s cycle. One inverter only; no SoC in the loop (SAFETY-25 Option A).

test_refusal.py drives the real Controller and gate; tools/replay_refusal.py
replays the 16-day capture. test_clamp.py and test_delivery.py re-baselined
where they asserted the old hold. DECISIONS/SAFETY-52 has the record and what
SAFETY-58/59 must take into account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:43:27 +02:00
admin 16c796fa30 Merge pull request 'TEL-14: PV sign declared per entity (pv_invert_entities)' (#124) from TEL-14-pv-sign into release/1.0 2026-09-25 01:30:33 +02:00
glenn schrooyenandClaude Opus 5.5 7559d21eda TEL-14: PV sign declared per entity (pv_invert_entities)
checks / unit (pull_request) Successful in 8m39s
Some PV entities (kWh meters, ESPHome clamps in the PV circuit) read positive
for consumption and negative for production. `pv_invert_entities` lists the
ones to flip; the flip is applied per member inside pv.total(), before the sum,
so the MQTT `pv` row and the status page carry the corrected figure. Default []
is byte-identical to 0.12.0.

Shape measured in supervisor_shape_probe.py section H (Supervisor 2026.08.0):
a parallel list validates the 0.12.0 blob unchanged and renders as its own
translated repeatable field; dicts on pv_entities refuse 0.12.0's stored
strings; a prefix is invisible syntax; a global bool cannot describe a mixed
roster. Status page marks flipped entities and cautions per entity below
-50 W, saying ADD or REMOVE. Telemetry only: control documents unchanged.

Record: DECISIONS/TEL-14-pv-sign.md (reverses TEL-11 section 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 00:25:24 +02:00
glenn schrooyenandClaude Opus 5.5 b06f1fad5f SAFETY-62 review: DOCS exit 3 at the command block (R1); recognise the auto store key (R2)
checks / unit (pull_request) Successful in 8m35s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 00:02:33 +02:00
glenn schrooyenandClaude Opus 5.5 f4f72211a9 SAFETY-60/62: operator tools refuse input they cannot read
checks / unit (pull_request) Successful in 8m31s
predeploy_check.py and rehearse_roster.py now assert a positive property of
their input (a JSON object, most of whose top-level keys are known option or
store keys) and refuse anything else with the shape named. The `ha apps info
--raw-json` wrapper, a truncated or list-wrapped state file and a wrapped
config no longer produce "CLEARED" / "NO FINDINGS" with exit 0.

Also: a clean --no-live-entities rehearsal exits 3, not 0; alloc_sim.py
exits 1 when split-invariance FAILS; decode_bus_capture.py takes several
paths and exits 1 on a file that is mostly not frames.

Graded by the new test_tool_inputs.py; DOCS.md says to pass .data.options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 23:27:50 +02:00
adminandClaude Opus 5 0ef9565071 TEL-12: several PV inverters, and one absent member is an absent TOTAL
checks / unit (pull_request) Successful in 7m59s
0.11.0 shipped `pv_entity` as a single string. The reference site's owner
reported within minutes of it going live that they have several PV inverters
and could configure one - which is ordinary for a multi-string roof, not a
property of one house. `pv_entities` is the roster and the row carries the
SITE total.

⚠️ THE HARD PART IS ABSENCE, NOT THE LIST. Three configured, one `unavailable`,
and the sum of the other two is a SMALLER NUMBER INDISTINGUISHABLE FROM A REAL
ONE. Read back by OPT-10 that says "there was less PV than there was" - the "no
irradiance" conclusion the channel exists to TEST. So `pv.total()` answers
`None` the moment any configured entity is absent: the topic carries HA's own
`None`, the entity goes to `unknown`, the recorder records a GAP. The
alternative (the partial sum beside a contributor count) was implemented and
run as mutation N1b; it loses because the MQTT row is read COLD a week later by
an attribution that would believe it, while the qualifying count would live in a
second entity with its own history. The partial information is not thrown away -
the STATUS PAGE names every entity that reported, with its watts, and every one
that did not, with its reason, because that surface is read LIVE by a human who
wants the diagnostic.

⚠️ AND THE MIRROR: a member honestly reporting 0 W is SUMMED. The test is
`is None`, never falsiness - a roster that blanked the site on a zero would
report an absence on every clear evening.

THE SURFACE IS MEASURED, NOT PREFERRED. `pv_entity` could not become a list:
tools/supervisor_shape_probe.py §G shows Supervisor 2026.08.0 answering an
0.11.0 install's stored string with `Invalid list for option 'pv_entity'`, and
deepmerge's list strategy is `override` so a `[]` default does not rescue it -
the live add-on would not start. `match(.*)?` does accept both shapes and is
rejected for a different, measured reason: it validates nothing (a string, a
list and the integer 417 all pass) and renders as a plain text box. So
`pv_entities` joins `pv_entity` and REPLACES it when set, CFG-04's rule for
`inverters:`, with a status caution saying which key won - a union would
double-count the inverter the old key still names.

STILL NOT A CONTROL INPUT. tools/tel11_pv_trace.py was extended rather than
replaced and now grades five claims over three document sets. The control hash
is UNMOVED - 846a10a5b89e… on `unset` and on `one`, on both trees - and the
roster control document is byte-identical between the two trees. 2/5 is a claim
TEL-11 could not make: with 0.11.0 as the baseline, "one entity behaves exactly
as 0.11.0 does" is a real cross-tree identity over control AND telemetry.

⚠️ TEL-11's OWN `--compare` REFUSAL HAD TO CHANGE, and that is MUTATION.md habit
24: a discriminator defined as "the baseline lacks this feature" dies on the
release that ships the feature, and dies as a false refusal.

Twelve mutations, each on a fresh purged `cp -a`, no survivors. N5 is +0 B
(`sum` -> `max`, the same three characters) and was run on a fresh copy with
`cmp` confirming the edit on disk. The fixture is three distinct NON-ZERO
contributions in two units with absence driven at EVERY position - a member
contributing 0 W could go absent without moving the sum of the rest, which is
habit 23 with a coinciding value in the arithmetic rather than in the plant.

31/31 in the CI image. Not deployable as merged: config.yaml stays at 0.11.0,
so predeploy_check.py reports a NO-OP until PKG cuts a version. A CHANGELOG
entry is wanted and deliberately not written - 11b forbids `## Unreleased` and
the bump is PKG's call; DECISIONS/TEL-12-several-pv-inverters.md is the
material it needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 22:13:25 +02:00
glenn schrooyenandClaude Opus 5 07ba3433ed TEL-11: PV production stops being invisible, and stays out of the control law
checks / unit (pull_request) Successful in 8m6s
OPT-10 measured that this site's pack reaches 90 % SoC on 2 days of 16 and that
20.5 kWh of surplus left the site while the pack had room - 12.6 kWh of it with
the battery not charging at all - and could not say WHY, because PV production
is recorded NOWHERE. Three causes need three different fixes (no irradiance:
nothing to fix; a charge-power limit; the optimiser declining to charge, which
is the only software defect) and guessing between them is how an optimiser gets
tuned to solve a cloud problem. This card adds the channel. It does not do the
attribution.

AN HA ENTITY, NOT A REGISTER, AND THAT IS A PRODUCT DECISION. This add-on's
RS485 bus carries the BATTERY inverter it drives; a household's PV inverter is
normally a different box from a different vendor reporting into Home Assistant
through its own integration. `pv_entity` takes whatever `sensor.*` the site's
own PV integration provides, any brand HA can read.

POSITIVE IS PRODUCTION, AND THERE IS NO `pv_invert`. The convention is derived
rather than assumed: `meter_entity` is positive when IMPORTING and `batt_entity`
is positive when DISCHARGING (DOCS.md, Sources), both of which are "power
arriving at the house's AC bus", and production arrives there too. Those two
keys carry invert flags because they are BIPOLAR and are READ BY THE CONTROL
PATH - `inv.batt` feeds SAFETY-30's per-unit sign check and SAFETY-31's
circulating-power sensor, so a reversed sensor hands the site a plausible number
it acts on. Production is unipolar and is read by nothing: a reversed sensor
announces itself as -4 kW at noon in the very history this card creates, and a
flag would only let an installer hide it. No negative-value alarm either - a PV
inverter's own standby draw makes a small negative reading at night ordinary, so
any threshold would be a number nobody measured.

ABSENCE IS NEVER ZERO, AND "DROPPED" IS NOT ABSENT. `publish()` SKIPS a None,
which is right for `circulation` and exactly wrong here: a skipped row leaves
Home Assistant holding the last number forever and the recorder draws a FLAT
LINE through the outage, which is indistinguishable from a measurement and
manufactures the "there was no surplus" conclusion this card exists to TEST. So
the absence is published, with HA's own payload: `mqtt/const.py` declares
`PAYLOAD_NONE = "None"` and `mqtt/sensor.py` tests it BEFORE the numeric branch,
so the entity's state becomes `unknown` and the recorder stores a GAP. Read out
of home-assistant/core `dev` on 2026-09-24 and quoted at the constant.
`ABSENT_AS_UNKNOWN` is a one-key set and the suite asserts `circulation: None`
is STILL dropped.

THE UNIT IS READ OFF THE ENTITY. `mW`, `W`, `kW`, `MW`, `GW`, `TW` - HA's own
`UnitOfPower`, every factor an exact power of ten. A sensor with no unit, or a
unit outside that table, is REFUSED by name and the status page says which.
`read_inputs()`'s `abs(raw) < 50` kW-or-W guess is safe for a quarter-hour
demand forecast and is NOT safe here: 30 W of dusk is an ordinary reading and
that heuristic would publish it as 30 kW.

GENUINELY OPTIONAL, AND NOT A SECOND `price_now_entity`. validate.py does not
mention the key - it cannot conflict with anything, because nothing else reads
it. The MQTT row lives in a new `OPTIONAL_SENSORS` list whose `include` argument
DEFAULTS TO EMPTY, so every caller that has never heard of `pv_entity` -
test_mqtt.py's fixtures, tools/rehearse_roster.py, anything written next year -
announces exactly what it announced before. Unset: no state call at all, no
entity, no topic, no status row, no page row. Set and failing: a CAUTION
(`ok=False, warn=True`), never a `bad` row, so a telemetry channel can never put
a battery site into NOT READY.

IT MUST NOT BECOME A CONTROL INPUT, AND THAT IS GRADED TWICE. `test_pv.py`
part 6 drives 40 cycles of the real `cycle()` and asserts the commands are
bit-identical across unset / no reading / 0 W / 8 kW, with a COMPUTED census
(33 modules under app/) showing the material is mentioned in exactly three:
pv.py, main.py, mqtt.py. `tools/tel11_pv_trace.py` is the cross-tree half -
`safety56_behaviour_trace.py`'s pattern with a DIFFERENT control clause, because
this card's claim is that nothing differs rather than that only sentences do: it
REFUSES unless the two `app/` fingerprints differ and unless exactly one side
reacts to the option. Measured on 41 stimulus cells:

    control   release/1.0 unset  846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
    control   TEL-11      unset  846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
    control   TEL-11      set    846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
    telemetry release/1.0 unset  a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
    telemetry TEL-11      unset  a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
    telemetry TEL-11      set    4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f

UNSET is byte-identical in BOTH documents, which is stronger than "the control
path is untouched": an install that does not set the option sees no change of
any kind, announcement and status rows included.

⚠️ AND THE CARD'S CENTRAL MUTATION WAS A SURVIVOR ON THE FIRST BATTERY.
`self.grid -= self.pv_w` in `read_inputs()` - +106 B, on disk, purged, genuinely
running - came back GREEN, because the fixture drove a 6 kW export over 12
cycles, which puts the loop AGAINST ITS RAIL: a command moving `slew_w` every
cycle produces the same ramp for -6000 W and for -14 000 W of apparent error.
MUTATION.md habit 23 with a saturated ACTUATOR in place of a coinciding value,
and it is in that file now as the habit's second instance. The fixture now
drives a 600 W import the loop SETTLES on, against a responsive meter, and asks
habit 23's own question as its own check: shift the METER by the same 8 kW and
confirm the commands really would have moved.

Evidence: 31/31 suites green in the CI image, checks.yml's exact command, purged
tree, `:ro`. Ten mutations, NO survivors, each on its own `cp -a` of the tree so
habit 18's revert hazard is closed by construction - table in
`DECISIONS/TEL-11-pv-production.md` §7 with byte deltas and sha256s.

⚠️ THIS IS A RUNTIME CHANGE BEHIND AN UNCHANGED VERSION, AND
`tools/predeploy_check.py` WILL REFUSE IT. `app/`, `config.yaml` and
`translations/` are all in its `RUNTIME` tuple and `config.yaml` still reads
0.10.1, so the gate reports a NO-OP deploy until a version is cut. That is PKG's
call and this card does not take it.

No CHANGELOG section: `test_version_gate.py` 11b refuses a newest `## ` heading
that is not config.yaml's `version:`, and opening one would mean taking that
same packaging decision. The operator account is in DOCS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 15:35:07 +02:00
adminandClaude Opus 5 8a6acd9fa2 SAFETY-56: the gate cannot tell a dead register from a pack that cannot comply, so stop saying it can
checks / unit (pull_request) Successful in 8m0s
Text only. No behaviour change - same conjunction, same limits, same MISSING,
same 0 W, same NOT READY. `tools/safety56_behaviour_trace.py` says so: 2390
capture reads, a 66-cell swept grid, and six driven controller worlds produce
the byte-identical document `6030175e...` on origin/release/1.0 and on this
branch, while the sentences differ.

THE DEFECT. SAFETY-37's conjunction is satisfied by two disjoint worlds - the
register has stopped reporting reality, or the SOURCE cannot comply with the
command - and nothing `freshness.py` is handed separates them. Three sentences
asserted the first. On the live 2026-09-21 episode it was the second: the pack
read 11 % for the whole 850 s hold, every command in that hold was a discharge,
and the register answered the first CHARGE command three seconds later. The
readiness row was worse still, because it asserted a false fact about a NAMED
entity that was present, numeric and updating - so the operator opened
Developer Tools, found it working, and had every reason to conclude the
diagnostics were broken.

THE FIX. Each message states what was OBSERVED and names BOTH explanations.
Three wordings, no shared helper: two are a log line about a behaviour, the
third is a banner about an entity, and a helper serving both would drop
whichever half the other caller does not need. SoC stays STATED, NOT WIRED -
part 11b drives the two worlds at 50 % and 11 % and the sentence does not move
one byte. The row does not branch on `batt_fresh.stale` either: `stale_note()`
already rides `liveness_notes()` a few rows below, so the discriminator is "is
that row there?", and part 11d asserts it is present in one world and absent in
the other.

THE CHECKS. test_freshness.py part 11 builds the pair of worlds with ground
truth written down rather than derived from the published stream, asserts they
are really two (40 of 40 reads differ) while the published stream is
bit-identical, and only then grades the sentences. Eleven mutations, all red:
each of the three sentences reverted (3, 2 and 2 named reds), the two
explanations transposed at ZERO bytes (1), the second explanation deleted (1),
the admission of ambiguity replaced by a ranking (1), all three reverted at
once (5), and two aimed at this card's own check - collapsing the world pair
(2) and emptying the accusation list, which a positive control against the
three sentences as 0.10.1 shipped them catches (1).

AND TWO OF THE ELEVEN FOUND DEFECTS IN THE CHECK RATHER THAN IN THE CODE.
Transposing `label` and `entity` in the row made it unfindable and the run died
on an IndexError - a red with no name and every later check ungraded, FCST-06's
shape exactly; `_s56_row()` returns a named absence now, 0 named reds before, 3
after. And the first invariance run compared a tree with itself, because it
archived a branch whose edits were not committed yet; the tool now REFUSES to
report "behaviour unchanged" unless the sentences halves differ, and both of
its refusals are driven.

SAFETY-57's part 10h check was the handoff and is inverted rather than deleted:
it required the accusation to still be present so that removing it had to be a
deliberate act. This is that act. Its escape clause is untouched.

Suite: 30/30 in the CI image, checks.yml's exact command, purged tree, :ro.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-23 21:02:40 +02:00
adminandClaude Opus 5 43c144b8d9 SAFETY-57: the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested
checks / unit (pull_request) Successful in 7m58s
SAFETY-37's freshness gate reports a battery-power register as MISSING when it
stops following its own command, and a missing input is "command 0 W and keep
commanding" (§11.1 rule 1). The gate's only exit was "an arrival carrying a
DIFFERENT number" - and at 0 W nothing is asking the register to be anywhere.
The state had no exit reachable from inside it, and the log line promised the
opposite in the same sentence that announced the 0 W.

REPRODUCED FROM THE WIRE, NOT MODELLED. scenarios/safety57-2026-09-21/ is 3840
verbatim frames off the RS-485 pair, read from the passive sniffer, decoded by
tools/decode_bus_capture.py (0 unparsed, 0 bad CRC, 0 unknown shapes; the
mode->sign convention is graded off the capture, not trusted). The box was
running 0.10.0, so the 0 W in the trace IS the add-on's own claim:

  10:51:24.7  0x0518 reaches -3 W. Pack at 11 %, the BMS refuses the discharge,
              the inverter idles. THE REGISTER IS HEALTHY AND TRUTHFUL.
  10:51:29.7  the command reaches +1060 W - 800 W of travel
  10:52:00    the conjunction arms. Inverter.batt becomes None
  10:52:21.7  the site commands 0 W, on the wire
  ...         0x0518 does not change value for 850.0 s. The setpoint does not
              change value for 790.0 s. The house exports 2.4 kW throughout
  ~11:04      a human restarts the add-on. That is what ends it

AND IT HAD BEEN ESCAPING BY LUCK. Replaying the committed 16-day loop capture
through the shipped conjunction finds 93 trips whose wait for the BMS
keep-alive pulse that cleared them has a median of 143.4 s, a worst case of
21440.1 s, and 17 of 93 at or beyond this episode's 710 s. That distribution
belongs to the battery's firmware, not to this code.

THE CARD'S OPTION 2 ALREADY HELD AND IS NOT THE BUG. _grade() has required
|standing| >= MOVE_W since SAFETY-37. The trip was at +1060 W and the stuck
state is at 0 W; they are different moments, and an entry-side rule cannot
reach a state entered legitimately and then persisted. Nothing in the
conjunction is weakened here.

THE RULE, SAFETY-13's SHAPE ONE MODULE OVER - which latches get an exit, not
how long the block lasts:

  the verdict is withdrawn when the obligation that produced it has been gone
  for RELEASE_S, and the register is then re-graded from scratch.

RELEASE_S is BATT_OBLIGED_S, 17.0 s - the obligation window already derived in
the file (6.0 plant + 10.0 publish + 1.0 read), taken at the FLOOR of its band
for the mirror of MOVE_W's reason. Which half is safe is the opposite of
SAFETY-13's: being wrong long is the failure being fixed, being wrong short
costs one bounded re-grading window. Below 17 s the release pre-empts the
cheaper exit - the register answering by itself - and that is graded
behaviourally, not asserted.

The whole parked interval is now stale_input_s + RELEASE_S = 32 s. On the live
episode: telemetry back at 10:52:39 instead of 11:05:35, with 0x0518 still
answering the same -3 W. RED BEFORE (release_s=inf, a graded counterfactual and
not a second implementation), GREEN AFTER.

WHAT IT COSTS, AND IT IS NOT SMALL. A register dead for good is re-graded for
ever: over 1201 reads the held number reaches a consumer on 630 (52.5 %)
against 35 (2.9 %) before. Payable only because one window is at most 35 s and
the two consumers hold for 75 s and 85 s, so neither can reach a verdict inside
one and the re-trip clears both windows. No detection SAFETY-37 had is given
back - a permanent None clears the same windows for ever too - and the command
is derived from the P1 meter, never from batt.

Two clocks, deliberately: changed_mono/unchanged_s() stay facts about the
REGISTER, graded_from/graded_s() are the VERDICT's window. Collapsing them
either lies to the operator or defeats the re-grading.

main.py's stale line had its ESCAPE CLAUSE corrected - it promised a clearance
the state could not produce - and gained a STALE WITHDRAWN edge that says in
its own words that nothing has been learned about the register. The DIAGNOSIS
half is SAFETY-56's and is deliberately untouched, with a named check asserting
it is still there so that rewrite has to be deliberate.

Battery: 22 mutations, 21 red, 1 argued equivalent, 0 unexplained survivors.
Two findings kept in DECISIONS: _release()'s move_w test swapped for
unchanged_s SURVIVED at +5 B because every fixture rested at exactly 0 W (habit
11, closed by a 0/100/300 W fixture); and the code shipped a habit-22 comment
claiming the _release/_grade order was load-bearing, which the battery
falsified - the two act on mutually exclusive predicates, so the order is not
observable. MUTATION.md gains habit 23 for a mutation that ran, fingerprinted
clean, and was a no-op on the fixture because `0.0 or x` is x.

Suite: 30/30 in the CI image, checks.yml's exact command, purged tree, :ro, one
file at a time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-21 12:33:56 +02:00
glenn schrooyenandClaude Opus 5 a6af332048 SAFETY-48: what the lead cap actually does to a frozen integrator, and what the peak figure depends on
checks / unit (pull_request) Successful in 7m55s
Four review findings, none of them in executable code. The control law, the
tests and the instruments' arithmetic are untouched; every number below was
re-measured against the committed 16-day capture before it was written down.

R1 - app/control.py carried a justification the decision record and MUTATION.md
habit 22 had already retracted, and it was false about the code it sits in. It
said an unconditional placement "would also pull the integrator toward a
command the SATURATION freeze is pinning", priced at 118 of 256 250 ticks. The
118 was measured against a different pair; on the pair the sentence names the
two forms are bit-identical - 0 of 256 246 ticks, re-measured. And the narrow
form that shipped DOES move a saturation-frozen integrator: it runs before the
freeze block and is not gated on `frozen`. Of 79 160 frozen ticks the cap
changes `i_w` on 1095, and on 679 of those it moves it further out on the side
it was already on - putting back magnitude the freeze had just let fall, which
the freeze's own rule forbids. Worst case 2424 W, at prev_w -3400 W, grid
+4697 W, actual -4144 W, sat_count 2 -> 3: the freeze leaves the integrator at
-176 W and the cap drags it to -2600 W. It never overrides a same-cycle refusal
(the freeze clipped on 75 512 ticks, the slew bound on none of them), and that
is stated too. It is also where SAFETY-13's settle values went: gating the cap
on `not frozen` puts test_control.py part 5 back at -1190 W exactly, while part
4(b) does not come back - that one moved for the winding reason.

R3 - the record read its own sensitivity table backwards. `fit_plant()` picks
lag 7.5 s on battery-power RMS, which separates it from 2.5 s by 0.3 %, while
the tool's own printed fidelity measure (export against the recorded 29.43 kWh)
prefers 2.5 s by the whole 35 % gap. Swept properly: at every lag <= 5.0 s
release/1.0's worst quarter is ALREADY 3434 W and this card changes it by
exactly zero. The 3739 -> 3434 W headline exists at one plant. New section 3a
carries the table; the export reduction now leads every summary and the peak is
presented as lag-dependent. The form-A rejection is strengthened rather than
weakened: form A is worse on the billed quarter at every lag where release/1.0
produces a peak at all, and at every top-N cut of the quarters.

R2 - DOCS.md stated modelled watts to the owner as measurement. It now leads
with the export reduction, says the figures are simulated, and says plainly
that no capacity-tariff improvement is promised.

R4 - tools/slew_sim.py --slew narrated "the OVER-ABSORPTION does not move, and
at the slowest slews it is slightly WORSE" while printing a monotonically
falling column. That sentence is TRUE on release/1.0 (reproduced:
958/1107/1133/1058/987/1029/1150/1143/1081) and false under this branch's law
(777/734/663/563/553/507/435/358/321), so SAFETY-39 is not retracted: its
record gets a forward reference, and the script now reads SLEW_LEAD_STEPS out
of the law it imported and narrates from the column it just printed. Both
branches of that narration were driven.

R5 - three counts in the L table were decoration and are re-driven in the CI
image on a `cp -a`, purged, `:ro`, one file at a time: L5 is 5+5 with the
function names exchanged and 5+4 with the branch bodies exchanged (two
mutations wearing one name, now listed separately), L6 is 6+3. The separable-
state count is grid-dependent and three sweeps disagree, so all three are
quoted with their grids and the load-bearing claim is WHERE they separate.

R7 - "a large import answered too weakly" is not what the aggregate shows. Form
A cuts import by 5.45 kWh and export by 5.18 kWh, and paired per tick delivers
8.56 kWh less offtake energy on the ticks release/1.0 was importing. The harm
is a tail redistribution inside quarters that already net to offtake. The
conclusion stands; the sentence is narrowed to it.

Verified: the full suite in the CI image, purged tree, `:ro` mount, one file at
a time, exactly the command in .gitea/workflows/checks.yml - 30/30, exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-19 02:37:01 +02:00
glenn schrooyenandClaude Opus 5 03ef61fa3b SAFETY-48: the slew step ceiling reaches the integrator, and what it bounds is the LEAD
`compute()` freezes the integrator with an asymmetric rule and the comment
beside it states the requirement outright - "the requirement is that the
INTEGRATOR stop accumulating, not only the command" - but `frozen` is computed
from ONE signal, `abs(prev_w - actual_w) > saturation_w`, and slew-limiting
never enters it. The integrator moves `gain * error` a cycle and the command
moves at most `slew_w`, so the integrator outruns the command whenever
`|error| > slew_w / gain`: 667 W at the live shape, against a measured median
grid error of 1114 W at the onset of a recorded episode. SAFETY-39's retune
made it MORE frequent, not less - a smaller step is a lower threshold.

⚠️ THE FORM THE CARD ASKED FOR RAISES THE BILLED QUANTITY, WHICH IS WHY IT IS
NOT WHAT SHIPPED. Reusing the asymmetric freeze on a carried slew flag was
built from release/1.0's own source and measured over 16.00 days / 256 252
meter instants of the live install's recorder: export 39.61 -> 34.43 kWh, and
the worst quarter-hour average OFFTAKE 3739 -> 3935 W with the mean quarter
+2.73 W, in five of six plant and tuning variants. A frozen integrator stops at
`prev + gain*error`, which undershoots by (1 - gain); for an export that is
free and for an import it is offtake, for as long as the ramp lasts. The card
said "do not invent a second rule"; the measurement says to, and both sentences
are recorded.

WHAT SHIPS BOUNDS THE LEAD AT ONE SLEW STEP, inside the branch where the slew
actually bound. Same 16 days, same ticks, same disturbance, same plant, same
seed: export 39.61 -> 34.07 kWh, wasted throughput 4.67 -> 3.19 kWh, battery
sign reversals 2992 -> 2770, median recovery 232 -> 199 s, p99 849 -> 850 s,
max 901 -> 898 s, unrecovered 53 -> 31, worst |i_w - prev_w| 9200 -> 800 W -
and the worst 15-minute average offtake 3739 -> 3434 W, i.e. the billed
quantity IMPROVES. `i_w = slewed` (a cap of zero) is better again at 32.02 kWh
and is refused: it is the velocity form this module's docstring says `i_w=None`
collapses to.

TWO COMMITTED NUMBERS MOVED, DELIBERATELY, WITH THE REASON BESIDE THEM.
SAFETY-13's released command settles at -800 W and -1200 W where release/1.0
settled at -1190 W and -2390 W, because there is less wound integrator to
settle onto; AC 4's property - it stops at the frozen integrator, not the rail
- is what is asserted and the second is now graded against `i_w` rather than a
literal. And SAFETY-39's "the slew does not own the over-absorption" is now
FALSE - INVERTED, on the independent first-order plant test_control.py writes
itself: 1005/1109/1285/1330 W rising with a slower slew becomes 729/550/424/326
falling. Two plant models that share nothing agree on the sign.

Evidence: `tools/replay_recorded_loop.py` plus the read-only capture it made
(scenarios/loop-2026-09-02_2026-09-17). The "before" law is the shipped file
with the block removed by a named substitution - never a transcription - and a
missing anchor is a REFUSAL carrying "NOTHING HAS BEEN MEASURED". The
instrument prints its own fidelity first: it reproduces 39.6 kWh of export
where the install exported 29.4, i.e. it is ~35 % pessimistic about the loop's
tracking, so only the delta is claimed.

30/30 suites green, including test_control.py's exhaustive i_w=None
equivalence sweep against the transcribed release/1.0 law, untouched: the cap
moves `i_w` and not `target`, and that sweep compares (target_w, sat_count,
frozen, reason).

Six law mutations, one instrument battery. L4 - the cap applied
unconditionally, -92 B - was a SURVIVOR of all thirty files: it is
bit-identical on the shipped configuration (0 of 256 246 ticks) and separable
on 2050 of 257 400 swept states, all at `integrator_max_w` above `max_w`, which
part 4 now drives. L5 and L6 are ZERO BYTES.

AND TWO COMMENTS IN THIS CARD WERE INVENTED AND ARE CORRECTED IN PLACE. The
one-sided spelling was justified by a two-sided form that would "drag a
trailing integrator" - it does not, and the two agree on 205 920 of 205 920
swept states; and a "118 of 256 252 ticks" figure had been measured against a
different pair. MUTATION.md gains habits 21 and 22 for both shapes.

NOT DEPLOYED, NOT VERSIONED. The live install still runs release/1.0's law.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-19 02:36:28 +02:00
glenn schrooyenandClaude Opus 5 b548525d10 TEL-10: the loop's integrator stops being invisible
`self.i_w` has been a separate quantity from the command since SAFETY-04 and
has left the process nowhere at all - no entity, no MQTT attribute, no log
line. The investigation behind SAFETY-48 could only RECONSTRUCT it from the
grid trace plus a replay; the reconstruction reproduced about a third of the
engagements it was checked against and was discarded, so the card it was
gathered for had to be argued from a measurement nobody could stand behind.
It could not have been made to work either: `i_w` depends on every cycle
before it, including the ones where the arbiter overrode the loop and
`main.py` re-seeded `self.i_w = self.target`, and that re-seed is invisible in
the grid trace.

THREE SITE ROWS, APPENDED AFTER release/1.0's EIGHT. `loop_i` is
`Decision.i_w` - the accumulator after the cycle, before the output clamp, the
slew limit and quantisation. `loop_lead` is `decision.i_w - prev_w` against the
command `compute()` was HANDED, which is not a subtraction of the other two
published rows: `Setpoint` is the arbiter's output and is this cycle's, so on
any cycle the arbiter overrode the loop the two differ - and that is exactly
the cycle on which the integrator was re-seeded. `loop_reason` is
`compute()`'s own word; the arbiter's `explain()` prints the winning claim's
NAME and has never carried the claim's reason, so `slew-limited` appears in
neither the `Why` row nor `Controller status`. Measured on the same cycle:
arbiter `'loop -> -5000 W'`, loop `'i-clamped'`.

NOTHING IN THE CONTROL PATH MOVED. `app/control.py` is not in this diff.
`compute()`, its returned `Decision` and every constant it reads are identical
to release/1.0, and all nine of release/1.0's retained discovery messages are
byte-identical and still FIRST in the announcement.

Evidence: 30/30 suites green in the CI image. Nine mutations, ONE survivor,
reported as one: `decision.i_w - self.target` for the lead is +2 B and green
because `cycle()` has not reassigned `self.target` at that line - the name is
kept for the coupling, not for a behaviour it buys, and M2b (+8 B) is the
mutation that does change the value and is red.

AND TWO OF THIS CARD'S OWN CHECKS COULD NOT REACH THE FAILURE THEY NAMED.
"the published integrator reaches its bound" was GREEN under the card's named
mutation (publish `prev_w`, -3 B), because the command reaches the bound too -
twelve cycles later. It asserts the LEAD TIME now and is red on 5. "a stopped
controller publishes not-running" was GREEN under deleting that branch's
assignments (-45 B, -67 B), because the fixture STARTED stopped and `__init__`
writes the same values; it stops the controller part-way through a run that
has already wound the integrator to -5000 W. Both are MUTATION.md habit 4,
found by the battery rather than by review.

No CHANGELOG section: `test_version_gate.py` 11b refuses a newest `## `
heading that is not config.yaml's `version:`, and opening one would mean
taking a packaging decision this card does not get to take. The operator
account is in DOCS.md and the release note belongs to the next PKG ticket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-18 23:36:23 +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
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
glenn schrooyenandClaude Opus 5 22ca341459 ARCH-02: assert the invariant, because a fixture cannot enumerate arrangements
checks / unit (pull_request) Successful in 6m55s
Review approved and then attacked part 5b, the control that stands between B1's
defect and a green suite. It got through in three edits:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 13:20:49 +02:00