master cannot be tagged yet, because release/1.0 ships three tickets the 0.10.0 changelog does not describe — one of them a control-law change, one three new MQTT entities. This is the changelog half. It touches no runtime file: the diff is goodwe_controller/CHANGELOG.md and nothing else.
The decision: folded into ## 0.10.0, not cut as 0.11.0
Verified before relying on it. 0.10.0 was cut by PKG-14 on 2026-09-16 and has never been built or pulled anywhere:
PKG-14's own predeploy transcript, committed in this file, reads deployed on the box : 0.7.0.
This repository carries exactly one tag, v0.2.1 on 5184a2c, and release_counter: 0.
No commit since 86bdf49 records a deploy.
HANDOVER.md records the install as a Local add-on at /addons/goodwe_controller, slug local_goodwe_controller.
So the string 0.10.0 does not yet name a tree anyone is running, and the PKG-06/PKG-07 no-op rule that forbade re-cutting 0.9.0 does not reach it. Cutting 0.11.0 would burn a number on a release that never left the repository.
⚠️The argument is conditional and the section now says so, with the check that settles it. Run against a box already reporting 0.10.0, tools/predeploy_check.py refuses with THE DEPLOY WOULD BE A NO-OP and names the runtime commits since the bump. config.yaml stays at 0.10.0, so test_version_gate.py 11b still finds the two files agreeing.
SAFETY-48 — and what it does not promise
The entry says in its heading and its first line that the control law changed, and that nothing in the section's preamble or in predeploy_check.py will warn an operator about it, because it touches no option, default or validator.
What it is measured to buy is less export: 39.61 → 34.07 kWh over the committed 16-day replay, 14 % of the replayed figure, falling at every plant and tuning variant swept (~4 % at gain: 0.3 to ~16 % at a re-fitted gain/offset, median variant near an eighth).
⚠️It is explicitly NOT a capacity-tariff improvement, and the entry says so in those words. At every plant lag ≤ 5.0 s — including the 2.5 s the instrument's own fidelity measure prefers — release/1.0's worst quarter is already 3434 W and this changes it by exactly zero. The 3739 → 3434 W figure exists only at the single longest lag fit_plant() selects, on an objective that separates it from its neighbour by 0.3 %. A reader is told plainly not to expect their billed quarter to improve.
The costs are in the entry rather than in the decision record only: the cap moves a saturation-frozen integrator on 1095 of 79 160 frozen ticks and 679 of those outward; SAFETY-13's settle values moved; SAFETY-39's "the slew does not own the over-absorption" is now false, inverted. Every number is simulated and the instrument is ~35 % pessimistic about export, so only the delta is claimed.
TEL-10 — three new site sensors
Loop integrator (goodwe_loop_integrator, W), Loop integrator lead (goodwe_loop_integrator_lead, W) and Loop reason (goodwe_loop_reason, eight values), appended after the existing nine, site rows rather than per-unit, both numbers state_class: measurement. Nothing in the control path moved.
The entry carries the two things a reader would otherwise get wrong — the lead is not a subtraction of two published rows, and Loop reason is not the Why row — and it repeats neither of the two sentences TEL-10's own review commit (b6c6da3) retracted: eight messages stay first and the ninth, goodwe_ctl/availability, is byte-identical but deliberately moves to last.
CI-9 — deliberately not in this file, and said so rather than omitted silently
CI-9 changes one file, .gitea/workflows/checks.yml, which is outside goodwe_controller/ entirely: not in the built image, not in predeploy_check.py's RUNTIME list, not observable on any version of the add-on. This file is the release note the Supervisor shows beside the version number, and the rule applied is that a row here has to be something the person deciding whether to press Update could act on.
⚠️ The note in the file says the rule is about audience, not importance — CI-9 repaired a merge gate that was grading the wrong tree, which is the same class of defect this ticket exists to clean up after, and it is still not operator-facing. If a future CI ticket changes what the shipped image contains, it belongs here.
The stray ## Unreleased — not touched, and that is the decision
It is the 0.8.0-era block PKG-14 annotated, sitting below ## 0.9.0 because the file is newest-first and it was written before 0.9.0 was cut. Every row in it has shipped and the annotation says which release each went to; re-filing it is PKG-08's card.
⚠️It is also load-bearing.test_version_gate.py 11d pins the count of ## Unreleased blocks below the newest section at exactly one — so deleting it is red on the commit that does it, and so is adding a second.
Two of PKG-14's measured figures went stale, and are re-derived rather than adjusted
Re-run through predeploy_check.is_runtime() itself: 32 runtime commits since 86bdf49, not 28, of which the contents table now accounts for 24, not 21. The other eight are the three misfiled tickets (SAFETY-43 ×2, SAFETY-44 ×3, TEST-21 ×2) and cb3f686 — PKG-14's own bump, which edits config.yaml and so counts as runtime; it was HEAD when PKG-14 measured and is inside the range now.
PKG-14's CLEARED, exit 0 transcript was not re-run, and now carries the check that says why it still holds: config.yaml is untouched by both tickets; app/validate.py imports neither main nor mqtt, and the three names it takes from control — SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S — are byte-identical across cb3f686..HEAD.
Verified
The full suite in the CI image, on a cp -a of the tree, purged, :ro mount, one file at a time — exactly the command in .gitea/workflows/checks.yml: 30/30, exit 0. test_version_gate.py part 11 green, including 11b ('0.10.0' in config.yaml, '0.10.0' in CHANGELOG.md), 11a's one-contents-table control and 11d's ## Unreleased count of exactly 1.
Not in scope
No deploy, no tag, no master. Phase 2 (merge release/1.0 into master--no-ff and tag) waits on this PR being reviewed and merged, and on an explicit go.
`master` cannot be tagged yet, because `release/1.0` ships three tickets the 0.10.0 changelog does not describe — one of them a **control-law change**, one **three new MQTT entities**. This is the changelog half. It touches **no runtime file**: the diff is `goodwe_controller/CHANGELOG.md` and nothing else.
## The decision: folded into `## 0.10.0`, not cut as 0.11.0
**Verified before relying on it.** 0.10.0 was cut by PKG-14 on 2026-09-16 and has never been built or pulled anywhere:
* PKG-14's own predeploy transcript, committed in this file, reads `deployed on the box : 0.7.0`.
* This repository carries **exactly one tag**, `v0.2.1` on `5184a2c`, and `release_counter: 0`.
* No commit since `86bdf49` records a deploy.
* `HANDOVER.md` records the install as a **Local** add-on at `/addons/goodwe_controller`, slug `local_goodwe_controller`.
So the string `0.10.0` does not yet name a tree anyone is running, and the PKG-06/PKG-07 no-op rule that forbade re-cutting `0.9.0` does not reach it. Cutting 0.11.0 would burn a number on a release that never left the repository.
⚠️ **The argument is conditional and the section now says so, with the check that settles it.** Run against a box already reporting `0.10.0`, `tools/predeploy_check.py` refuses with `THE DEPLOY WOULD BE A NO-OP` and names the runtime commits since the bump. `config.yaml` stays at `0.10.0`, so `test_version_gate.py` 11b still finds the two files agreeing.
## SAFETY-48 — and what it does *not* promise
The entry says in its heading and its first line that **the control law changed**, and that nothing in the section's preamble or in `predeploy_check.py` will warn an operator about it, because it touches no option, default or validator.
What it is measured to buy is **less export**: 39.61 → 34.07 kWh over the committed 16-day replay, **14 %** of the replayed figure, falling at every plant and tuning variant swept (~4 % at `gain: 0.3` to ~16 % at a re-fitted gain/offset, median variant near an eighth).
⚠️ **It is explicitly NOT a capacity-tariff improvement, and the entry says so in those words.** At every plant lag ≤ 5.0 s — including the 2.5 s the instrument's own fidelity measure prefers — release/1.0's worst quarter is *already* 3434 W and this changes it by **exactly zero**. The 3739 → 3434 W figure exists only at the single longest lag `fit_plant()` selects, on an objective that separates it from its neighbour by 0.3 %. A reader is told plainly not to expect their billed quarter to improve.
The costs are in the entry rather than in the decision record only: the cap moves a saturation-frozen integrator on **1095 of 79 160** frozen ticks and **679** of those outward; SAFETY-13's settle values moved; SAFETY-39's *"the slew does not own the over-absorption"* is now **false, inverted**. Every number is simulated and the instrument is ~35 % pessimistic about export, so only the delta is claimed.
## TEL-10 — three new site sensors
`Loop integrator` (`goodwe_loop_integrator`, W), `Loop integrator lead` (`goodwe_loop_integrator_lead`, W) and `Loop reason` (`goodwe_loop_reason`, eight values), appended after the existing nine, site rows rather than per-unit, both numbers `state_class: measurement`. Nothing in the control path moved.
The entry carries the two things a reader would otherwise get wrong — the lead is **not** a subtraction of two published rows, and `Loop reason` is **not** the `Why` row — and it repeats **neither** of the two sentences TEL-10's own review commit (`b6c6da3`) retracted: **eight** messages stay first and the ninth, `goodwe_ctl/availability`, is byte-identical but deliberately moves to **last**.
## CI-9 — deliberately not in this file, and said so rather than omitted silently
CI-9 changes one file, `.gitea/workflows/checks.yml`, which is outside `goodwe_controller/` entirely: not in the built image, not in `predeploy_check.py`'s `RUNTIME` list, not observable on any version of the add-on. This file is the release note the **Supervisor shows beside the version number**, and the rule applied is that a row here has to be something the person deciding whether to press *Update* could act on.
⚠️ The note in the file says the rule is about **audience, not importance** — CI-9 repaired a merge gate that was grading the wrong tree, which is the same class of defect this ticket exists to clean up after, and it is still not operator-facing. If a future CI ticket changes what the shipped image contains, it belongs here.
## The stray `## Unreleased` — not touched, and that is the decision
It is the **0.8.0-era block PKG-14 annotated**, sitting below `## 0.9.0` because the file is newest-first and it was written before 0.9.0 was cut. Every row in it has shipped and the annotation says which release each went to; re-filing it is **PKG-08**'s card.
⚠️ **It is also load-bearing.** `test_version_gate.py` 11d pins the count of `## Unreleased` blocks below the newest section at **exactly one** — so deleting it is red on the commit that does it, and so is adding a second.
## Two of PKG-14's measured figures went stale, and are re-derived rather than adjusted
Re-run through `predeploy_check.is_runtime()` itself: **32** runtime commits since `86bdf49`, not 28, of which the contents table now accounts for **24**, not 21. The other eight are the three misfiled tickets (SAFETY-43 ×2, SAFETY-44 ×3, TEST-21 ×2) and `cb3f686` — PKG-14's own bump, which edits `config.yaml` and so counts as runtime; it was `HEAD` when PKG-14 measured and is inside the range now.
PKG-14's `CLEARED, exit 0` transcript was **not** re-run, and now carries the check that says why it still holds: `config.yaml` is untouched by both tickets; `app/validate.py` imports neither `main` nor `mqtt`, and the three names it takes from `control` — `SLEW_RAIL_FRAC`, `LOOP_TICK_S`, `PLANT_SETTLE_S` — are byte-identical across `cb3f686..HEAD`.
## Verified
The full suite in the CI image, on a `cp -a` of the tree, purged, `:ro` mount, one file at a time — exactly the command in `.gitea/workflows/checks.yml`: **30/30, exit 0**. `test_version_gate.py` part 11 green, including 11b (`'0.10.0'` in `config.yaml`, `'0.10.0'` in `CHANGELOG.md`), 11a's one-contents-table control and 11d's `## Unreleased` count of exactly 1.
## Not in scope
No deploy, no tag, no `master`. Phase 2 (merge `release/1.0` into `master` `--no-ff` and tag) waits on this PR being reviewed and merged, and on an explicit go.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
0.10.0 was cut by PKG-14 on 2026-09-16 and three tickets have merged into
release/1.0 since. Two of them change the built image - one is a CONTROL LAW
change and one adds three MQTT entities - and neither is described anywhere in
this file. Tagging that tree today would stamp a number on contents the
changelog does not account for.
FOLDED RATHER THAN CUT AS 0.11.0, AND THE ARGUMENT IS CONDITIONAL.
0.10.0 has never been built or pulled anywhere: the reference install is on
0.7.0 (PKG-14's own predeploy run records `deployed on the box : 0.7.0`), the
repository carries exactly one tag, `v0.2.1`, and no release. So the string
`0.10.0` does not yet name a tree anyone is running, and the PKG-06/PKG-07
no-op that forbade re-cutting `0.9.0` does not reach it. Cutting 0.11.0 would
burn a number on a release that never left the repository. The condition is
checkable and the section now says so: run against a box already reporting
0.10.0, `tools/predeploy_check.py` refuses with THE DEPLOY WOULD BE A NO-OP and
names the runtime commits since the bump. `config.yaml` stays at 0.10.0, so
`test_version_gate.py` 11b still finds the two sides equal.
SAFETY-48 IS THE ENTRY THAT MATTERED MOST TO GET RIGHT, AND WHAT IT PROMISES
IS LESS EXPORT - NOT A LOWER BILL. The measured result is export 39.61 -> 34.07
kWh over the committed 16-day replay, 14 % of the replayed figure, falling at
every plant and tuning variant swept (~4 % to ~16 %, median near an eighth).
The section says in its own heading and its first line that the control law
changed, and it says PLAINLY that the billed quarter is not a claim: at every
plant lag <= 5.0 s - including the one that best reproduces the recorded export
- release/1.0's worst quarter is ALREADY 3434 W and this changes it by exactly
zero. The 3739 -> 3434 W headline exists at the single longest fitted lag, and
the lag is not identified by the recording. The costs are in the entry too: the
cap moves a saturation-frozen integrator on 1095 of 79 160 frozen ticks, 679 of
them outward; SAFETY-13's settle values moved; SAFETY-39's "the slew does not
own the over-absorption" is now false.
TEL-10 gets the three site rows, what each one is, and the two things a reader
would otherwise get wrong: the lead is NOT a subtraction of two published rows,
and `Loop reason` is not the `Why` row. It repeats neither of the two sentences
TEL-10's own review retracted - EIGHT messages stay first and the ninth,
`goodwe_ctl/availability`, is byte-identical but deliberately moves to last.
CI-9 IS DELIBERATELY NOT IN THIS FILE, AND THE OMISSION IS WRITTEN DOWN RATHER
THAN SILENT. It changes one file, `.gitea/workflows/checks.yml`, outside
`goodwe_controller/` entirely: not in the built image, not in `is_runtime()`'s
list, and not observable on any version of the add-on. This file is the release
note the Supervisor shows beside the version number, and a row here has to be
something the person deciding whether to press Update could act on. The note
above SAFETY-48 says so and says the rule is about audience, not importance.
THE STRAY `## Unreleased` AT LINE 1777 IS NOT TOUCHED, AND THAT IS A DECISION.
It is the 0.8.0-era block PKG-14 annotated: every row in it has shipped, the
annotation says which release each went to, and re-filing it is PKG-08's card.
It is also load-bearing - `test_version_gate.py` 11d pins the count of
`## Unreleased` blocks below the newest section at EXACTLY one, so deleting it
is red on the commit that does it, and so is adding a second.
TWO MEASURED FIGURES IN PKG-14'S PROSE WENT STALE AND ARE RE-DERIVED, NOT
ADJUSTED. The runtime-commit count since `86bdf49` is now 32, not 28, of which
the contents table accounts for 24, not 21; both re-run through
`predeploy_check.is_runtime()` itself. The other eight are the three misfiled
tickets (SAFETY-43 x2, SAFETY-44 x3, TEST-21 x2) and `cb3f686`, PKG-14's own
bump, which edits `config.yaml` and so counts as runtime - it was HEAD when
PKG-14 measured and is inside the range now.
PKG-14's `CLEARED, exit 0` predeploy transcript was NOT re-run and now carries
the check that says why it still holds: `config.yaml` is untouched by both
tickets; `app/validate.py` imports neither `main` nor `mqtt`, and the three
names it takes from `control` - SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S -
are byte-identical across cb3f686..HEAD.
Verified: the full suite in the CI image, `cp -a` of the tree, purged, `:ro`
mount, one file at a time, exactly the command in .gitea/workflows/checks.yml -
30/30, exit 0. No runtime file is in this diff.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
NON-BLOCKING — the two tie figures are swapped against the record they come from.
The entry reads:
At gain: 0.3 and at slew_w set to the rail the mean-quarter effect is a tie (−0.17 W and +0.16 W)
Named A-then-B with values x-then-y, that pairs gain: 0.3 → −0.17 W and the rail → +0.16 W. DECISIONS/SAFETY-48-bounding-the-integrator-lead.md's own --sensitivity block has it the other way round:
slew_w 555.6 (the rail) 36.5 -> 34.4 3434 -> 3434 -0.17
gain 0.3 28.1 -> 27.0 3434 -> 3434 +0.16
and §7's prose names them in the same order as the table (slew_w: 555.6 and gain: 0.3 → −0.17 and +0.16).
What a reader would wrongly do. Both are ties, so nothing operational turns on it — but gain: 0.3 is the one regime the record singles out with "at gain: 0.3 it would not be [worth shipping]". Someone who runs a low gain and reads this line comes away believing their regime is the one that moves the mean quarter the right way, when it is the one that moves it very slightly the wrong way. Swapping the two values (or naming them in the record's order) fixes it.
Everything else in the entry reproduces: 39.61 → 34.07 kWh and 5.54 / 39.61 = 14.0 %; 4.67 → 3.19; 2992 → 2770; 232 → 199 s; 53 → 31; 9200 → 800 W; 256 252 instants / 16.00 days; ~4 % at gain: 0.3 (3.9 %) and ~16 % at the re-fitted gain/offset (16.0 %); 1095 of 79 160 frozen ticks, 679 outward, worst 2424 W; SAFETY-13 −800/−1200 against −1190/−2390; the SAFETY-39 inversion 1005/1109/1285/1330 → 729/550/424/326; the rejected form's 3732/3711/3935/4326 against 3434/3434/3739/3954 and +2.73 W; 375 W RMS and 39.6 vs 29.4 kWh ≈ +35 %. test_control.py's SAFETY-04 i_w=None sweep is genuinely untouched by SAFETY-48 — the ticket's three hunks in that file are at lines 522, 546 and 1115, none of them in the sweep.
**NON-BLOCKING — the two tie figures are swapped against the record they come from.**
The entry reads:
> At `gain: 0.3` and at `slew_w` set to the rail the mean-quarter effect is a tie (−0.17 W and **+0.16 W**)
Named A-then-B with values x-then-y, that pairs `gain: 0.3` → −0.17 W and the rail → +0.16 W. `DECISIONS/SAFETY-48-bounding-the-integrator-lead.md`'s own `--sensitivity` block has it the other way round:
```
slew_w 555.6 (the rail) 36.5 -> 34.4 3434 -> 3434 -0.17
gain 0.3 28.1 -> 27.0 3434 -> 3434 +0.16
```
and §7's prose names them in the same order as the table (`slew_w: 555.6` and `gain: 0.3` → −0.17 and +0.16).
**What a reader would wrongly do.** Both are ties, so nothing operational turns on it — but `gain: 0.3` is the one regime the record singles out with *"at `gain: 0.3` it would not be [worth shipping]"*. Someone who runs a low gain and reads this line comes away believing their regime is the one that moves the mean quarter the *right* way, when it is the one that moves it very slightly the wrong way. Swapping the two values (or naming them in the record's order) fixes it.
Everything else in the entry reproduces: 39.61 → 34.07 kWh and 5.54 / 39.61 = 14.0 %; 4.67 → 3.19; 2992 → 2770; 232 → 199 s; 53 → 31; 9200 → 800 W; 256 252 instants / 16.00 days; ~4 % at `gain: 0.3` (3.9 %) and ~16 % at the re-fitted gain/offset (16.0 %); 1095 of 79 160 frozen ticks, 679 outward, worst 2424 W; SAFETY-13 −800/−1200 against −1190/−2390; the SAFETY-39 inversion 1005/1109/1285/1330 → 729/550/424/326; the rejected form's 3732/3711/3935/4326 against 3434/3434/3739/3954 and +2.73 W; 375 W RMS and 39.6 vs 29.4 kWh ≈ +35 %. `test_control.py`'s SAFETY-04 `i_w=None` sweep is genuinely untouched by SAFETY-48 — the ticket's three hunks in that file are at lines 522, 546 and 1115, none of them in the sweep.
NON-BLOCKING — "touch three files between them" is false as written, in the one paragraph that has to survive an audit.
The note under PKG-14's committed CLEARED, exit 0 transcript says:
The two tickets folded in below touch three files between them - app/control.py, app/main.py and app/mqtt.py - and config.yaml is not among them
They touch 74 files. git diff --stat cb3f686..HEAD is 81 files changed overall; TEL-10 alone is 7 and SAFETY-48 is 74 (DOCS.md, MUTATION.md, two DECISIONS/, test_control.py, test_mqtt.py, test_telemetry.py, tools/replay_recorded_loop.py, tools/slew_sim.py, and 65 recorded-scenario files).
Three is the count of files matching predeploy_check.py's RUNTIME tuple, which is the count the argument actually needs.
What a reader would wrongly do. This paragraph is the sole evidence that a transcript nobody re-ran is still true, so it invites exactly the reader who checks it — and the first thing that reader runs is git diff --stat, which contradicts the sentence's headline number before they reach the part that matters. The claim then has to be rescued by guessing what "files" meant. Suggest "touch three runtime files between them" or "three files under predeploy_check.py's RUNTIME".
Verified independently, and the operative claim holds. Filtering cb3f686..origin/pkg15-changelog through RUNTIME (app/, translations/, config.yaml, Dockerfile, run.sh, requirements.txt, build.yaml) returns exactly app/control.py, app/main.py, app/mqtt.py — config.yaml is untouched, as are the other five runtime paths. app/validate.py's imports, read by AST rather than by grep, are logging plus arbitrage, control, custom_formula, inverter, maintenance, p1, peakguard, price, shadow, static_tariff, watchdog — no main, no mqtt, at module level or anywhere else in the file. From control it takes exactly SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S, and all three are byte-identical at the same line numbers on cb3f686 and on this head (PLANT_SETTLE_S = 18.0, LOOP_TICK_S = 2.0, SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S). The argument that the transcript still holds is sound; only its opening count is wrong.
One smaller thing in the same paragraph: "it imports exactly SLEW_RAIL_FRAC, LOOP_TICK_S and PLANT_SETTLE_S, and all three are unchanged (2.0, 18.0, and their quotient)" lists the names in one order and the values in another, so the names read off positionally give SLEW_RAIL_FRAC = 2.0. "their quotient" is the only thing that disambiguates it.
**NON-BLOCKING — "touch three files between them" is false as written, in the one paragraph that has to survive an audit.**
The note under PKG-14's committed `CLEARED, exit 0` transcript says:
> The two tickets folded in below touch three files between them - `app/control.py`, `app/main.py` and `app/mqtt.py` - and **`config.yaml` is not among them**
They touch **74** files. `git diff --stat cb3f686..HEAD` is `81 files changed` overall; TEL-10 alone is 7 and SAFETY-48 is 74 (`DOCS.md`, `MUTATION.md`, two `DECISIONS/`, `test_control.py`, `test_mqtt.py`, `test_telemetry.py`, `tools/replay_recorded_loop.py`, `tools/slew_sim.py`, and 65 recorded-scenario files).
Three is the count of files matching `predeploy_check.py`'s `RUNTIME` tuple, which is the count the argument actually needs.
**What a reader would wrongly do.** This paragraph is the *sole* evidence that a transcript nobody re-ran is still true, so it invites exactly the reader who checks it — and the first thing that reader runs is `git diff --stat`, which contradicts the sentence's headline number before they reach the part that matters. The claim then has to be rescued by guessing what "files" meant. Suggest "touch three **runtime** files between them" or "three files under `predeploy_check.py`'s `RUNTIME`".
**Verified independently, and the operative claim holds.** Filtering `cb3f686..origin/pkg15-changelog` through `RUNTIME` (`app/`, `translations/`, `config.yaml`, `Dockerfile`, `run.sh`, `requirements.txt`, `build.yaml`) returns exactly `app/control.py`, `app/main.py`, `app/mqtt.py` — `config.yaml` is untouched, as are the other five runtime paths. `app/validate.py`'s imports, read by AST rather than by grep, are `logging` plus `arbitrage`, `control`, `custom_formula`, `inverter`, `maintenance`, `p1`, `peakguard`, `price`, `shadow`, `static_tariff`, `watchdog` — **no `main`, no `mqtt`**, at module level or anywhere else in the file. From `control` it takes exactly `SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S`, and all three are byte-identical at the same line numbers on `cb3f686` and on this head (`PLANT_SETTLE_S = 18.0`, `LOOP_TICK_S = 2.0`, `SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S`). The argument that the transcript still holds is sound; only its opening count is wrong.
One smaller thing in the same paragraph: *"it imports exactly `SLEW_RAIL_FRAC`, `LOOP_TICK_S` and `PLANT_SETTLE_S`, and all three are unchanged (`2.0`, `18.0`, and their quotient)"* lists the names in one order and the values in another, so the names read off positionally give `SLEW_RAIL_FRAC = 2.0`. "their quotient" is the only thing that disambiguates it.
NON-BLOCKING — the arithmetic is right; the reason given for one of the four is not.
The numbers reproduce exactly. I re-derived them with predeploy_check.py's own RUNTIME tuple and is_runtime() rule, walking git log --format='%H %s' and git show --name-only --format= per commit, the way runtime_commits_since_version_change() does:
Those 24 map onto the contents table's 20 ticket rows without a remainder: FCST-04 carries two commits, SAFETY-48 carries two, and the two conflict-resolving merges 435e1b6 / f5ea397 belong to the TARIFF-14 and TARIFF-12 rows. 20 rows ↔ 24 commits. ✅
PKG-14's superseded pair was right when it was taken: 86bdf49..cb3f686^ is 28, and the table at cb3f686 had 18 rows ↔ 21 commits. ✅
The defect is the explanation. The annotation says:
plus cb3f686, PKG-14's own bump … it was HEAD when PKG-14 counted, so last..HEAD excluded it then and includes it now.
A A..B range includesB. Had cb3f686 been HEAD when the count was taken, 86bdf49..HEAD would have counted it and PKG-14's figure would have been 29, not 28. git log -1 --format='%H %P' cb3f686 gives parent 587495e, and 86bdf49..587495e is 28 — which is what PKG-14 published. So what actually happened is the plain thing: the bump commit did not exist yet when PKG-14 measured; it was the commit PKG-14 was about to write. It enters the count now because it is in the range now, not because a range boundary changed behaviour.
What a reader would wrongly do. Someone re-deriving this later — which is the whole point of writing the derivation down rather than the number — follows the stated mechanism, computes 86bdf49..cb3f686, gets 29 against a documented 28, and concludes one of the two figures is wrong. Worse, they carry away a false rule about how A..B works and apply it to the no-op check, where last..HEAD excluding last is load-bearing in the opposite direction.
The decision to annotate rather than overwrite is right and I have no objection to it: PKG-14's 28/21 were correct measurements, not errors, and a reader who finds 28 in a quoted transcript and 32 in the prose needs to see why both are true.
Fixing this is a one-clause edit — "it did not exist when PKG-14 counted; its parent 587495e was HEAD" — and it does not change a single figure.
**NON-BLOCKING — the arithmetic is right; the reason given for one of the four is not.**
**The numbers reproduce exactly.** I re-derived them with `predeploy_check.py`'s own `RUNTIME` tuple and `is_runtime()` rule, walking `git log --format='%H %s'` and `git show --name-only --format=` per commit, the way `runtime_commits_since_version_change()` does:
* `86bdf49..origin/pkg15-changelog` → **32**. ✅
* Subtract SAFETY-43 (`b21e229`, `2271e14`), SAFETY-44 (`f6d7bfc`, `d1b7e3f`, `5e382f5`), TEST-21 (`f76639c`, `57775d4`) and `cb3f686` → 8, leaving **24**. ✅
* Those 24 map onto the contents table's 20 ticket rows without a remainder: FCST-04 carries two commits, SAFETY-48 carries two, and the two conflict-resolving merges `435e1b6` / `f5ea397` belong to the TARIFF-14 and TARIFF-12 rows. 20 rows ↔ 24 commits. ✅
* PKG-14's superseded pair was right when it was taken: `86bdf49..cb3f686^` is **28**, and the table at `cb3f686` had 18 rows ↔ **21** commits. ✅
**The defect is the explanation.** The annotation says:
> plus `cb3f686`, PKG-14's own bump … **it was `HEAD` when PKG-14 counted, so `last..HEAD` excluded it then** and includes it now.
A `A..B` range *includes* `B`. Had `cb3f686` been `HEAD` when the count was taken, `86bdf49..HEAD` would have counted it and PKG-14's figure would have been 29, not 28. `git log -1 --format='%H %P' cb3f686` gives parent `587495e`, and `86bdf49..587495e` is 28 — which is what PKG-14 published. So what actually happened is the plain thing: **the bump commit did not exist yet when PKG-14 measured**; it was the commit PKG-14 was about to write. It enters the count now because it is in the range now, not because a range boundary changed behaviour.
**What a reader would wrongly do.** Someone re-deriving this later — which is the whole point of writing the derivation down rather than the number — follows the stated mechanism, computes `86bdf49..cb3f686`, gets 29 against a documented 28, and concludes one of the two figures is wrong. Worse, they carry away a false rule about how `A..B` works and apply it to the no-op check, where `last..HEAD` excluding `last` is load-bearing in the opposite direction.
The decision to **annotate rather than overwrite** is right and I have no objection to it: PKG-14's 28/21 were correct measurements, not errors, and a reader who finds 28 in a quoted transcript and 32 in the prose needs to see why both are true.
Fixing this is a one-clause edit — "it did not exist when PKG-14 counted; its parent `587495e` was `HEAD`" — and it does not change a single figure.
NON-BLOCKING — the audience rule is right, but this file already breaks it one screen away, and the note reads as though it does not.
The exclusion's facts all check out. PR #95 merged as 16b1d6c and its diff is .gitea/workflows/checks.yml | 243 +++-, one file, 1 file changed — outside goodwe_controller/ entirely, no match against predeploy_check.py's RUNTIME tuple, and goodwe_controller/Dockerfile copies only app/ and run.sh, so it is genuinely not in the built image. The rule itself — a row here has to be something the person deciding whether to press Update could act on — is the right rule for a file the Supervisor renders beside a version number, and stating the exclusion beats omitting it silently.
The inconsistency is inside the same file.## 0.8.0's contents table carries:
| `9e42cec` | CI-06 - `test_peakguard.py` answered two of its checks with the wall clock |
9e42cec's diff is goodwe_controller/CHANGELOG.md and goodwe_controller/test_peakguard.py — a test-only change. Not in RUNTIME; not in the image (the Dockerfile copies no test_*.py); nothing an operator can observe on any version. By the rule this PR states, CI-06 had no business in a contents table either — and it got a row.
What a reader would wrongly do. A reader who wants to know whether a CI ticket belongs in this file has two answers in it: a row for CI-06 and a paragraph explaining why CI-9 gets none. The likelier conclusion is not "the rule changed in 0.10.0" but "CI-9 was dropped and someone wrote a justification afterwards" — which is precisely the reading the note exists to prevent, and it undercuts an argument that is otherwise correct.
Cheapest fix is to name the precedent rather than leave it to be found: CI-06 is the counterexample, it is in a released section, re-filing it is PKG-08's card and not this one's. That converts the weakest thing about the note into the thing that proves it was checked.
I also read the file for the opposite failure — an operator-facing change quietly left out — and did not find one. The 24 accounted runtime commits map onto the table's 20 rows with no remainder, and the only unaccounted commits are the three misfiled tickets the file already names and PKG-14's own bump.
**NON-BLOCKING — the audience rule is right, but this file already breaks it one screen away, and the note reads as though it does not.**
The exclusion's facts all check out. PR #95 merged as `16b1d6c` and its diff is `.gitea/workflows/checks.yml | 243 +++-`, one file, `1 file changed` — outside `goodwe_controller/` entirely, no match against `predeploy_check.py`'s `RUNTIME` tuple, and `goodwe_controller/Dockerfile` copies only `app/` and `run.sh`, so it is genuinely not in the built image. The rule itself — *a row here has to be something the person deciding whether to press Update could act on* — is the right rule for a file the Supervisor renders beside a version number, and stating the exclusion beats omitting it silently.
**The inconsistency is inside the same file.** `## 0.8.0`'s contents table carries:
```
| `9e42cec` | CI-06 - `test_peakguard.py` answered two of its checks with the wall clock |
```
`9e42cec`'s diff is `goodwe_controller/CHANGELOG.md` and `goodwe_controller/test_peakguard.py` — a test-only change. Not in `RUNTIME`; not in the image (the Dockerfile copies no `test_*.py`); nothing an operator can observe on any version. By the rule this PR states, CI-06 had no business in a contents table either — and it got a row.
**What a reader would wrongly do.** A reader who wants to know whether a CI ticket belongs in this file has two answers in it: a row for CI-06 and a paragraph explaining why CI-9 gets none. The likelier conclusion is not "the rule changed in 0.10.0" but "CI-9 was dropped and someone wrote a justification afterwards" — which is precisely the reading the note exists to prevent, and it undercuts an argument that is otherwise correct.
Cheapest fix is to name the precedent rather than leave it to be found: CI-06 is the counterexample, it is in a released section, re-filing it is PKG-08's card and not this one's. That converts the weakest thing about the note into the thing that proves it was checked.
I also read the file for the opposite failure — an operator-facing change quietly left out — and did not find one. The 24 accounted runtime commits map onto the table's 20 rows with no remainder, and the only unaccounted commits are the three misfiled tickets the file already names and PKG-14's own bump.
Review verdict: sound — merging. Four comments above, none blocking.
1. Does the SAFETY-48 entry stop a reader concluding their billed peak improves? Yes. Read as an operator, not as an auditor.
The thing that would have sunk it — the 3739 → 3434 W figure sitting in the A/B table where the eye lands — is not there. The table carries export, wasted throughput, sign reversals, median recovery, unrecovered and worst |i_w − prev_w|, and no worst-quarter row. The peak figure appears exactly once, inside the paragraph that takes it away, after the sentence that names it as existing at one plant model. That is the right structural choice and it is what makes the entry work rather than the wording.
Both required statements are present and both are unhedged:
"at every lag ≤ 5.0 s, including the one that best reproduces the recorded export, release/1.0's worst quarter is already 3434 W and this change moves it by exactly zero" — matches §3a's sweep (lags 0.0 / 2.5 / 5.0 all read 3434 → 3434).
"fit_plant() selects a 7.5 s inverter lag on battery-power RMS, which separates it from 2.5 s by 0.3 %" — 375.3 W against 376.4 W, and the fidelity measure prefers 2.5 s (28.9 kWh against a recorded 29.43) by the whole 35 % gap. ✅
And it closes on the only sentence that can be made: "it does not make it worse at any lag tried — not that it improves it." A hurried reader gets "less export, not a better bill" from the preamble and cannot get anything else from the entry.
2. Is the control-law change findable? Yes, in two places, and the preamble block earns its position.
It sits at line 87, immediately after the three config-refusal bullets and before the 0.7.0 upgrade warning — inside the first screen of the section, not appended at the end of it. It opens on the thing that makes it necessary ("WHICH NOTHING ABOVE AND NO CONFIG CHECK WILL TELL YOU ABOUT"), which is the correct justification for breaking an otherwise uniform config-refusal preamble: the surrounding material is a list of ways the add-on will refuse to start, and a change that refuses nothing is invisible in that frame unless it is called out against it. The contents-table row carries ⚠️ THE CONTROL LAW CHANGED as a second, independent hit for a reader who skips prose. This works.
3. The stray ## Unreleased — verified, not accepted.
test_version_gate.py 11d reads _stale = [n for _, n in _heads[1:] if n.lower() == "unreleased"] and asserts len(_stale) == 1 — an equality, so the count is pinned in both directions exactly as claimed. I did not stop at reading it: on a cp -a of this tree with the block (lines 1995–2204, between ## 0.9.0 and ## 0.8.0) deleted, the gate goes
Deleting it really is red on the commit that does it. Leaving it alone is correct. It is also the block PKG-14 annotated — its own text carries the ANNOTATED BY PKG-14, 2026-09-16 header — and it sits below ## 0.9.0 because the file is newest-first, as claimed. The block moved 1777 → 1995, not 1975; I mention it only because a wrong line number in a brief is how a search ends up in the wrong section.
4, 5, 6 — see comments above. Arithmetic ✅, exclusion justified ✅, transcript argument verified independently ✅; three non-blocking defects, none of which changes a figure or a decision.
Suite
CI run 810 is success on 5aba220 for checks / unit (pull_request). On this host test_version_gate.py part 11 is green end to end — 11a, 11b ('0.10.0' in config.yaml, '0.10.0' in CHANGELOG.md), 11c and 11d — and the nine failures outside part 11 are identical on origin/release/1.0, i.e. the bare-host aiohttp skip predeploy_check.py's own docstring describes, not anything this PR introduces.
Why this merges with three defects open
All three are corrections to how something is explained, not to what is claimed: a swapped pair of tie values that are both ties, a file count that should say "runtime", and a wrong reason for a right number. None of them can lead an operator to press Update expecting something this release does not deliver, which is the failure this file exists to prevent. They are worth fixing in a follow-up — the reason clause in particular, since a derivation written down to be re-derivable should not teach a false rule about A..B — but holding a changelog that is correct where it counts, in order to fix prose that is wrong where it does not, would cost a round for nothing.
Merged; release/1.0 SHA confirmed from the remote after the merge.
## Review verdict: **sound — merging.** Four comments above, none blocking.
### 1. Does the SAFETY-48 entry stop a reader concluding their billed peak improves? **Yes.** Read as an operator, not as an auditor.
The thing that would have sunk it — the 3739 → 3434 W figure sitting in the A/B table where the eye lands — is not there. The table carries export, wasted throughput, sign reversals, median recovery, unrecovered and worst `|i_w − prev_w|`, and **no worst-quarter row**. The peak figure appears exactly once, *inside* the paragraph that takes it away, after the sentence that names it as existing at one plant model. That is the right structural choice and it is what makes the entry work rather than the wording.
Both required statements are present and both are unhedged:
* *"at every lag ≤ 5.0 s, including the one that best reproduces the recorded export, release/1.0's worst quarter is already 3434 W and this change moves it by exactly zero"* — matches §3a's sweep (lags 0.0 / 2.5 / 5.0 all read `3434 → 3434`).
* *"`fit_plant()` selects a 7.5 s inverter lag on battery-power RMS, which separates it from 2.5 s by 0.3 %"* — 375.3 W against 376.4 W, and the fidelity measure prefers 2.5 s (28.9 kWh against a recorded 29.43) by the whole 35 % gap. ✅
And it closes on the only sentence that can be made: *"it does not make it worse at any lag tried — not that it improves it."* A hurried reader gets "less export, not a better bill" from the preamble and cannot get anything else from the entry.
### 2. Is the control-law change findable? **Yes, in two places, and the preamble block earns its position.**
It sits at line 87, immediately after the three config-refusal bullets and before the 0.7.0 upgrade warning — inside the first screen of the section, not appended at the end of it. It opens on the thing that makes it necessary (*"WHICH NOTHING ABOVE AND NO CONFIG CHECK WILL TELL YOU ABOUT"*), which is the correct justification for breaking an otherwise uniform config-refusal preamble: the surrounding material is a list of ways the add-on will refuse to start, and a change that refuses nothing is invisible in that frame unless it is called out against it. The contents-table row carries **⚠️ THE CONTROL LAW CHANGED** as a second, independent hit for a reader who skips prose. This works.
### 3. The stray `## Unreleased` — **verified, not accepted.**
`test_version_gate.py` 11d reads `_stale = [n for _, n in _heads[1:] if n.lower() == "unreleased"]` and asserts `len(_stale) == 1` — an equality, so the count is pinned in both directions exactly as claimed. I did not stop at reading it: on a `cp -a` of this tree with the block (lines 1995–2204, between `## 0.9.0` and `## 0.8.0`) deleted, the gate goes
```
FAIL 11d. exactly 0 `## Unreleased` block(s) remain BELOW the newest section...
```
Deleting it really is red on the commit that does it. Leaving it alone is correct. It is also the block PKG-14 annotated — its own text carries the `ANNOTATED BY PKG-14, 2026-09-16` header — and it sits below `## 0.9.0` because the file is newest-first, as claimed. The block moved 1777 → **1995**, not 1975; I mention it only because a wrong line number in a brief is how a search ends up in the wrong section.
### 4, 5, 6 — see comments above. Arithmetic ✅, exclusion justified ✅, transcript argument verified independently ✅; three non-blocking defects, none of which changes a figure or a decision.
### Suite
CI run 810 is `success` on `5aba220` for `checks / unit (pull_request)`. On this host `test_version_gate.py` part 11 is green end to end — 11a, 11b (`'0.10.0'` in `config.yaml`, `'0.10.0'` in `CHANGELOG.md`), 11c and 11d — and the nine failures outside part 11 are identical on `origin/release/1.0`, i.e. the bare-host `aiohttp` skip `predeploy_check.py`'s own docstring describes, not anything this PR introduces.
### Why this merges with three defects open
All three are corrections to how something is *explained*, not to what is *claimed*: a swapped pair of tie values that are both ties, a file count that should say "runtime", and a wrong reason for a right number. None of them can lead an operator to press *Update* expecting something this release does not deliver, which is the failure this file exists to prevent. They are worth fixing in a follow-up — the reason clause in particular, since a derivation written down to be re-derivable should not teach a false rule about `A..B` — but holding a changelog that is correct where it counts, in order to fix prose that is wrong where it does not, would cost a round for nothing.
Merged; `release/1.0` SHA confirmed from the remote after the merge.
admin
merged commit 8a5736136a into release/1.02026-09-19 19:08:05 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
mastercannot be tagged yet, becauserelease/1.0ships three tickets the 0.10.0 changelog does not describe — one of them a control-law change, one three new MQTT entities. This is the changelog half. It touches no runtime file: the diff isgoodwe_controller/CHANGELOG.mdand nothing else.The decision: folded into
## 0.10.0, not cut as 0.11.0Verified before relying on it. 0.10.0 was cut by PKG-14 on 2026-09-16 and has never been built or pulled anywhere:
deployed on the box : 0.7.0.v0.2.1on5184a2c, andrelease_counter: 0.86bdf49records a deploy.HANDOVER.mdrecords the install as a Local add-on at/addons/goodwe_controller, sluglocal_goodwe_controller.So the string
0.10.0does not yet name a tree anyone is running, and the PKG-06/PKG-07 no-op rule that forbade re-cutting0.9.0does not reach it. Cutting 0.11.0 would burn a number on a release that never left the repository.⚠️ The argument is conditional and the section now says so, with the check that settles it. Run against a box already reporting
0.10.0,tools/predeploy_check.pyrefuses withTHE DEPLOY WOULD BE A NO-OPand names the runtime commits since the bump.config.yamlstays at0.10.0, sotest_version_gate.py11b still finds the two files agreeing.SAFETY-48 — and what it does not promise
The entry says in its heading and its first line that the control law changed, and that nothing in the section's preamble or in
predeploy_check.pywill warn an operator about it, because it touches no option, default or validator.What it is measured to buy is less export: 39.61 → 34.07 kWh over the committed 16-day replay, 14 % of the replayed figure, falling at every plant and tuning variant swept (~4 % at
gain: 0.3to ~16 % at a re-fitted gain/offset, median variant near an eighth).⚠️ It is explicitly NOT a capacity-tariff improvement, and the entry says so in those words. At every plant lag ≤ 5.0 s — including the 2.5 s the instrument's own fidelity measure prefers — release/1.0's worst quarter is already 3434 W and this changes it by exactly zero. The 3739 → 3434 W figure exists only at the single longest lag
fit_plant()selects, on an objective that separates it from its neighbour by 0.3 %. A reader is told plainly not to expect their billed quarter to improve.The costs are in the entry rather than in the decision record only: the cap moves a saturation-frozen integrator on 1095 of 79 160 frozen ticks and 679 of those outward; SAFETY-13's settle values moved; SAFETY-39's "the slew does not own the over-absorption" is now false, inverted. Every number is simulated and the instrument is ~35 % pessimistic about export, so only the delta is claimed.
TEL-10 — three new site sensors
Loop integrator(goodwe_loop_integrator, W),Loop integrator lead(goodwe_loop_integrator_lead, W) andLoop reason(goodwe_loop_reason, eight values), appended after the existing nine, site rows rather than per-unit, both numbersstate_class: measurement. Nothing in the control path moved.The entry carries the two things a reader would otherwise get wrong — the lead is not a subtraction of two published rows, and
Loop reasonis not theWhyrow — and it repeats neither of the two sentences TEL-10's own review commit (b6c6da3) retracted: eight messages stay first and the ninth,goodwe_ctl/availability, is byte-identical but deliberately moves to last.CI-9 — deliberately not in this file, and said so rather than omitted silently
CI-9 changes one file,
.gitea/workflows/checks.yml, which is outsidegoodwe_controller/entirely: not in the built image, not inpredeploy_check.py'sRUNTIMElist, not observable on any version of the add-on. This file is the release note the Supervisor shows beside the version number, and the rule applied is that a row here has to be something the person deciding whether to press Update could act on.⚠️ The note in the file says the rule is about audience, not importance — CI-9 repaired a merge gate that was grading the wrong tree, which is the same class of defect this ticket exists to clean up after, and it is still not operator-facing. If a future CI ticket changes what the shipped image contains, it belongs here.
The stray
## Unreleased— not touched, and that is the decisionIt is the 0.8.0-era block PKG-14 annotated, sitting below
## 0.9.0because the file is newest-first and it was written before 0.9.0 was cut. Every row in it has shipped and the annotation says which release each went to; re-filing it is PKG-08's card.⚠️ It is also load-bearing.
test_version_gate.py11d pins the count of## Unreleasedblocks below the newest section at exactly one — so deleting it is red on the commit that does it, and so is adding a second.Two of PKG-14's measured figures went stale, and are re-derived rather than adjusted
Re-run through
predeploy_check.is_runtime()itself: 32 runtime commits since86bdf49, not 28, of which the contents table now accounts for 24, not 21. The other eight are the three misfiled tickets (SAFETY-43 ×2, SAFETY-44 ×3, TEST-21 ×2) andcb3f686— PKG-14's own bump, which editsconfig.yamland so counts as runtime; it wasHEADwhen PKG-14 measured and is inside the range now.PKG-14's
CLEARED, exit 0transcript was not re-run, and now carries the check that says why it still holds:config.yamlis untouched by both tickets;app/validate.pyimports neithermainnormqtt, and the three names it takes fromcontrol—SLEW_RAIL_FRAC,LOOP_TICK_S,PLANT_SETTLE_S— are byte-identical acrosscb3f686..HEAD.Verified
The full suite in the CI image, on a
cp -aof the tree, purged,:romount, one file at a time — exactly the command in.gitea/workflows/checks.yml: 30/30, exit 0.test_version_gate.pypart 11 green, including 11b ('0.10.0'inconfig.yaml,'0.10.0'inCHANGELOG.md), 11a's one-contents-table control and 11d's## Unreleasedcount of exactly 1.Not in scope
No deploy, no tag, no
master. Phase 2 (mergerelease/1.0intomaster--no-ffand tag) waits on this PR being reviewed and merged, and on an explicit go.🤖 Generated with Claude Code
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
NON-BLOCKING — the two tie figures are swapped against the record they come from.
The entry reads:
Named A-then-B with values x-then-y, that pairs
gain: 0.3→ −0.17 W and the rail → +0.16 W.DECISIONS/SAFETY-48-bounding-the-integrator-lead.md's own--sensitivityblock has it the other way round:and §7's prose names them in the same order as the table (
slew_w: 555.6andgain: 0.3→ −0.17 and +0.16).What a reader would wrongly do. Both are ties, so nothing operational turns on it — but
gain: 0.3is the one regime the record singles out with "atgain: 0.3it would not be [worth shipping]". Someone who runs a low gain and reads this line comes away believing their regime is the one that moves the mean quarter the right way, when it is the one that moves it very slightly the wrong way. Swapping the two values (or naming them in the record's order) fixes it.Everything else in the entry reproduces: 39.61 → 34.07 kWh and 5.54 / 39.61 = 14.0 %; 4.67 → 3.19; 2992 → 2770; 232 → 199 s; 53 → 31; 9200 → 800 W; 256 252 instants / 16.00 days; ~4 % at
gain: 0.3(3.9 %) and ~16 % at the re-fitted gain/offset (16.0 %); 1095 of 79 160 frozen ticks, 679 outward, worst 2424 W; SAFETY-13 −800/−1200 against −1190/−2390; the SAFETY-39 inversion 1005/1109/1285/1330 → 729/550/424/326; the rejected form's 3732/3711/3935/4326 against 3434/3434/3739/3954 and +2.73 W; 375 W RMS and 39.6 vs 29.4 kWh ≈ +35 %.test_control.py's SAFETY-04i_w=Nonesweep is genuinely untouched by SAFETY-48 — the ticket's three hunks in that file are at lines 522, 546 and 1115, none of them in the sweep.NON-BLOCKING — "touch three files between them" is false as written, in the one paragraph that has to survive an audit.
The note under PKG-14's committed
CLEARED, exit 0transcript says:They touch 74 files.
git diff --stat cb3f686..HEADis81 files changedoverall; TEL-10 alone is 7 and SAFETY-48 is 74 (DOCS.md,MUTATION.md, twoDECISIONS/,test_control.py,test_mqtt.py,test_telemetry.py,tools/replay_recorded_loop.py,tools/slew_sim.py, and 65 recorded-scenario files).Three is the count of files matching
predeploy_check.py'sRUNTIMEtuple, which is the count the argument actually needs.What a reader would wrongly do. This paragraph is the sole evidence that a transcript nobody re-ran is still true, so it invites exactly the reader who checks it — and the first thing that reader runs is
git diff --stat, which contradicts the sentence's headline number before they reach the part that matters. The claim then has to be rescued by guessing what "files" meant. Suggest "touch three runtime files between them" or "three files underpredeploy_check.py'sRUNTIME".Verified independently, and the operative claim holds. Filtering
cb3f686..origin/pkg15-changelogthroughRUNTIME(app/,translations/,config.yaml,Dockerfile,run.sh,requirements.txt,build.yaml) returns exactlyapp/control.py,app/main.py,app/mqtt.py—config.yamlis untouched, as are the other five runtime paths.app/validate.py's imports, read by AST rather than by grep, areloggingplusarbitrage,control,custom_formula,inverter,maintenance,p1,peakguard,price,shadow,static_tariff,watchdog— nomain, nomqtt, at module level or anywhere else in the file. Fromcontrolit takes exactlySLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S, and all three are byte-identical at the same line numbers oncb3f686and on this head (PLANT_SETTLE_S = 18.0,LOOP_TICK_S = 2.0,SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S). The argument that the transcript still holds is sound; only its opening count is wrong.One smaller thing in the same paragraph: "it imports exactly
SLEW_RAIL_FRAC,LOOP_TICK_SandPLANT_SETTLE_S, and all three are unchanged (2.0,18.0, and their quotient)" lists the names in one order and the values in another, so the names read off positionally giveSLEW_RAIL_FRAC = 2.0. "their quotient" is the only thing that disambiguates it.NON-BLOCKING — the arithmetic is right; the reason given for one of the four is not.
The numbers reproduce exactly. I re-derived them with
predeploy_check.py's ownRUNTIMEtuple andis_runtime()rule, walkinggit log --format='%H %s'andgit show --name-only --format=per commit, the wayruntime_commits_since_version_change()does:86bdf49..origin/pkg15-changelog→ 32. ✅b21e229,2271e14), SAFETY-44 (f6d7bfc,d1b7e3f,5e382f5), TEST-21 (f76639c,57775d4) andcb3f686→ 8, leaving 24. ✅435e1b6/f5ea397belong to the TARIFF-14 and TARIFF-12 rows. 20 rows ↔ 24 commits. ✅86bdf49..cb3f686^is 28, and the table atcb3f686had 18 rows ↔ 21 commits. ✅The defect is the explanation. The annotation says:
A
A..Brange includesB. Hadcb3f686beenHEADwhen the count was taken,86bdf49..HEADwould have counted it and PKG-14's figure would have been 29, not 28.git log -1 --format='%H %P' cb3f686gives parent587495e, and86bdf49..587495eis 28 — which is what PKG-14 published. So what actually happened is the plain thing: the bump commit did not exist yet when PKG-14 measured; it was the commit PKG-14 was about to write. It enters the count now because it is in the range now, not because a range boundary changed behaviour.What a reader would wrongly do. Someone re-deriving this later — which is the whole point of writing the derivation down rather than the number — follows the stated mechanism, computes
86bdf49..cb3f686, gets 29 against a documented 28, and concludes one of the two figures is wrong. Worse, they carry away a false rule about howA..Bworks and apply it to the no-op check, wherelast..HEADexcludinglastis load-bearing in the opposite direction.The decision to annotate rather than overwrite is right and I have no objection to it: PKG-14's 28/21 were correct measurements, not errors, and a reader who finds 28 in a quoted transcript and 32 in the prose needs to see why both are true.
Fixing this is a one-clause edit — "it did not exist when PKG-14 counted; its parent
587495ewasHEAD" — and it does not change a single figure.NON-BLOCKING — the audience rule is right, but this file already breaks it one screen away, and the note reads as though it does not.
The exclusion's facts all check out. PR #95 merged as
16b1d6cand its diff is.gitea/workflows/checks.yml | 243 +++-, one file,1 file changed— outsidegoodwe_controller/entirely, no match againstpredeploy_check.py'sRUNTIMEtuple, andgoodwe_controller/Dockerfilecopies onlyapp/andrun.sh, so it is genuinely not in the built image. The rule itself — a row here has to be something the person deciding whether to press Update could act on — is the right rule for a file the Supervisor renders beside a version number, and stating the exclusion beats omitting it silently.The inconsistency is inside the same file.
## 0.8.0's contents table carries:9e42cec's diff isgoodwe_controller/CHANGELOG.mdandgoodwe_controller/test_peakguard.py— a test-only change. Not inRUNTIME; not in the image (the Dockerfile copies notest_*.py); nothing an operator can observe on any version. By the rule this PR states, CI-06 had no business in a contents table either — and it got a row.What a reader would wrongly do. A reader who wants to know whether a CI ticket belongs in this file has two answers in it: a row for CI-06 and a paragraph explaining why CI-9 gets none. The likelier conclusion is not "the rule changed in 0.10.0" but "CI-9 was dropped and someone wrote a justification afterwards" — which is precisely the reading the note exists to prevent, and it undercuts an argument that is otherwise correct.
Cheapest fix is to name the precedent rather than leave it to be found: CI-06 is the counterexample, it is in a released section, re-filing it is PKG-08's card and not this one's. That converts the weakest thing about the note into the thing that proves it was checked.
I also read the file for the opposite failure — an operator-facing change quietly left out — and did not find one. The 24 accounted runtime commits map onto the table's 20 rows with no remainder, and the only unaccounted commits are the three misfiled tickets the file already names and PKG-14's own bump.
Review verdict: sound — merging. Four comments above, none blocking.
1. Does the SAFETY-48 entry stop a reader concluding their billed peak improves? Yes. Read as an operator, not as an auditor.
The thing that would have sunk it — the 3739 → 3434 W figure sitting in the A/B table where the eye lands — is not there. The table carries export, wasted throughput, sign reversals, median recovery, unrecovered and worst
|i_w − prev_w|, and no worst-quarter row. The peak figure appears exactly once, inside the paragraph that takes it away, after the sentence that names it as existing at one plant model. That is the right structural choice and it is what makes the entry work rather than the wording.Both required statements are present and both are unhedged:
3434 → 3434).fit_plant()selects a 7.5 s inverter lag on battery-power RMS, which separates it from 2.5 s by 0.3 %" — 375.3 W against 376.4 W, and the fidelity measure prefers 2.5 s (28.9 kWh against a recorded 29.43) by the whole 35 % gap. ✅And it closes on the only sentence that can be made: "it does not make it worse at any lag tried — not that it improves it." A hurried reader gets "less export, not a better bill" from the preamble and cannot get anything else from the entry.
2. Is the control-law change findable? Yes, in two places, and the preamble block earns its position.
It sits at line 87, immediately after the three config-refusal bullets and before the 0.7.0 upgrade warning — inside the first screen of the section, not appended at the end of it. It opens on the thing that makes it necessary ("WHICH NOTHING ABOVE AND NO CONFIG CHECK WILL TELL YOU ABOUT"), which is the correct justification for breaking an otherwise uniform config-refusal preamble: the surrounding material is a list of ways the add-on will refuse to start, and a change that refuses nothing is invisible in that frame unless it is called out against it. The contents-table row carries ⚠️ THE CONTROL LAW CHANGED as a second, independent hit for a reader who skips prose. This works.
3. The stray
## Unreleased— verified, not accepted.test_version_gate.py11d reads_stale = [n for _, n in _heads[1:] if n.lower() == "unreleased"]and assertslen(_stale) == 1— an equality, so the count is pinned in both directions exactly as claimed. I did not stop at reading it: on acp -aof this tree with the block (lines 1995–2204, between## 0.9.0and## 0.8.0) deleted, the gate goesDeleting it really is red on the commit that does it. Leaving it alone is correct. It is also the block PKG-14 annotated — its own text carries the
ANNOTATED BY PKG-14, 2026-09-16header — and it sits below## 0.9.0because the file is newest-first, as claimed. The block moved 1777 → 1995, not 1975; I mention it only because a wrong line number in a brief is how a search ends up in the wrong section.4, 5, 6 — see comments above. Arithmetic ✅, exclusion justified ✅, transcript argument verified independently ✅; three non-blocking defects, none of which changes a figure or a decision.
Suite
CI run 810 is
successon5aba220forchecks / unit (pull_request). On this hosttest_version_gate.pypart 11 is green end to end — 11a, 11b ('0.10.0'inconfig.yaml,'0.10.0'inCHANGELOG.md), 11c and 11d — and the nine failures outside part 11 are identical onorigin/release/1.0, i.e. the bare-hostaiohttpskippredeploy_check.py's own docstring describes, not anything this PR introduces.Why this merges with three defects open
All three are corrections to how something is explained, not to what is claimed: a swapped pair of tie values that are both ties, a file count that should say "runtime", and a wrong reason for a right number. None of them can lead an operator to press Update expecting something this release does not deliver, which is the failure this file exists to prevent. They are worth fixing in a follow-up — the reason clause in particular, since a derivation written down to be re-derivable should not teach a false rule about
A..B— but holding a changelog that is correct where it counts, in order to fix prose that is wrong where it does not, would cost a round for nothing.Merged;
release/1.0SHA confirmed from the remote after the merge.