run_tariff_refresh() promised this in its own docstring:
⚠️AND A FAILURE DOES NOT CLEAR THE PLAN. … Zeroing the plan here would replace a coarse tariff with no tariff, which is the opposite of what §11.4 asks for.
It guarded the wrong thing. load_prices() states in its own docstring that it "RETURNS, NEVER RAISES, for every failure a site can actually have", because a degradation arriving as a traceback into the planning tick would stop the controller regulating over an expired certificate. So a dead feed is a return: the try succeeded, _degrade() handed back a §11.4 plan, and self.tariff_plan, self.tariff_at = plan, now overwrote the good one. The except Exception was reachable only by something nobody expected — a supplier outage, the case the docstring names first, never reached it.
With static_profile unset — the shipped default, and the normal state of a dynamic-tariff site — _degrade() returns TariffPlan(no_data, None, "none"). A complete 48-hour plan replaced by nothing.
Fourth card in one family: FCST-04's M4 (not ok only), FCST-05's HIGH ((JSONDecodeError, OSError) only), FCST-06 (not self.ok only), now except Exception only for a path that never raises.
Reproduction
Driving the shipped chain with nothing stubbed but the socket — the transport raises the way aiohttp does, load_prices() turns it into a no_data read, _degrade() into a §11.4 plan, and run_tariff_refresh() decides. One good fetch, then a ClientConnectorError 1800 s later; TARIFF_MAX_AGE_S = 5400 s, so the standing plan is fresh.
Before:
after a good fetch origin='provider' n_points=48 'dynamic_dayahead from generic_dayahead, 48 h'
after the failed fetch origin='none' n_points=0 'no tariff - generic_dayahead: ClientConnectorError: Cannot connect to host ...'
tariff_at moved: True
did resolve_tariff RAISE? no: it RETURNED TariffPlan(origin='none') has_prices=False
After:
after the failed fetch origin='provider' n_points=48 'dynamic_dayahead from generic_dayahead, 48 h'
tariff_at moved: False
…and the log line the operator gets:
tariff refresh degraded (no tariff - generic_dayahead: ClientConnectorError: Cannot connect
to host api.energy-charts.info:443 ssl:default); KEEPING the plan resolved at
2026-09-12 18:03:46+00:00, 1800 s ago. tariff_at is NOT moved, so it retires 5400 s after
that fetch and arbitrage stops with it - a kept plan is never presented as a current one.
The fix
One question, asked once, after the attempt, from the evidence that exists — FCST-06's answer, one module over:
TariffPlan.from_feed (new, on the class that owns origin's vocabulary) answers did this resolution observe the configured feed. fresh_tariff_plan() is reused, not transcribed — it is already the one place the age is judged, and section 8b pins that reuse on the AST.
The §11.4 static fallback — the judgement this card had to make
A fallback does not replace a fresh provider plan, although it carries real prices. Every point _degrade() builds is provisional=True, so firm_points() is empty, arbitrage() refuses and cheap_now() is False — everything a consumer may act on is already off — while a day-ahead price for a given hour does not change once published, so a plan fetched 30 minutes ago is exactly as true about 19:00 as one fetched now. The fallback is less actionable and no more current; it takes over when the observation retires, which is what §11.4 is for.
And the rule is deliberately not symmetrical. A standing plan that is itself a degradation is replaced by the next one, and its timestamp moves — otherwise an outage would freeze the operator's static profile at the window it was first generated for, and a price_provider: off install would flip between kept and re-derived every 90 minutes for no behavioural gain.
Each of the three terms is the only false one in some check (MUTATION.md habit 17).
tariff_at does not move on a kept plan
It is the moment of the fetch that produced the plan standing, and fresh_tariff_plan() judges the age from it. Re-stamping it would be the refresh asserting the feed answered when it did not — the "freeze at its last value and look current forever" hazard the same docstring names — and would leave TARIFF_MAX_AGE_Sexactly as dead as it was before this card. Leaving it alone is what makes the bound govern, and it is the whole of "distinguishable from a freshly-fetched one".
Acceptance criteria
A returned degradation does not replace a plan still within TARIFF_MAX_AGE_S
A kept plan still ages out (driven at ±the bound) and is distinguishable — tariff_at unmoved, plus a WARNING naming the failure, the fetch kept, its age and its retirement
TARIFF_MAX_AGE_S now governs: it is the serve-stale window, consulted on every refresh
Docstring and behaviour agree; DOCS.md and the stale "so the bound rarely matters" clause updated with it
A check that drives a returned degradation, not a raised exception (section 8b — a check that patched resolve_tariff to raise would have passed against the defect)
29/29 test files green
Mutations — eleven run, eleven red, no survivors
Re-driven on the merged tree, not carried over from before TARIFF-10 landed: the surrounding code changed, so a red against the old base is not evidence about the new one.
Purged before every run; reverts fingerprinted after, not only before (habit 18); harness backup under mktemp.
#
mutation
Δ
named check that went red
M1
guard removed entirely (the original defect restored)
+10 B
a RETURNED degradation does not replace a plan still inside TARIFF_MAX_AGE_S (+3 more)
M2
not plan.from_feed → not plan.has_prices
+1 B
a §11.4 static FALLBACK does not replace a fresh provider plan either
M3
standing.from_feed term dropped
−23 B
a standing plan that is ITSELF a §11.4 fallback IS replaced by the next one
M4
fresh_tariff_plan(now) → self.tariff_plan
−11 B
a plan that has aged PAST 5400 s IS replaced; run_tariff_refresh() asks fresh_tariff_plan()
M5
tariff_at re-stamped on a kept plan
+41 B
…and tariff_at did NOT move (+1 more)
M6
from_feed → origin != "none"
−4 B
from_feed is true of exactly the observation (+2 more)
M7
from_feed → has_prices
−10 B
from_feed is true of exactly the observation (+2 more)
M8
not plan.from_feed → True
−14 B
when the feed answers again the observation replaces the kept plan
M9
M3 + M4 together (TEST-16 masking)
−34 B
3 checks red
M10
standing is not None term dropped
−49 B
a degradation with NO plan standing at all is stored (+1 more)
M11
guard made conditional on arbitrage.enabled(self.o) (the TARIFF-10 seam)
+86 B
5 checks red, incl. run_tariff_refresh() READS no trading option
Merged after TARIFF-10 — what the wider gate changed, and what it did not
TARIFF-10 changed which installs run this task; this card changed what the task does on a failed fetch. Git auto-merged the two hunks in main.py, which is exactly when the interaction needs reading. Nothing in the reasoning changed, and the two newly reachable configurations are now driven in section 8b rather than reasoned about:
A provider configured with trading disarmed — the case this guard was designed for. It needs no second fixture, because nothing on this path reads arbitrage_enabled at all, and that is asserted rather than assumed. ⚠️ Two drafts of that check were red against a correct tree: the first matched the docstring paragraph that saysarbitrage_enabled is not read, the second matched the log line's "arbitrage, where it is armed". A substring test over a function grades its prose as much as its behaviour (habit 4, third costume), so the check now scans the AST's symbols and its non-docstring string constants. A fixture that set the option and asserted no difference would have been the other defect — comparing a thing against itself.
price_provider: off + a static_profile — the config TARIFF-10's or protects. It resolves to the configured product (origin: "static"), so standing.from_feed is false on every refresh and the guard never latches; driven twice in a row, because a guard that latched there would freeze a static site's plan at its first window for the life of the install. M3 is red on that check too.
Two more facts settle the rest: the keep decision is strictly downstream of resolve_tariff(), so the §8.3 carry into price_history — TARIFF-10's whole point — is unaffected either way; and the kept-plan log line said "arbitrage stops with it", which after TARIFF-10 is false on a site that fetches without trading, so it now says "arbitrage, where it is armed, stops with it".
CHANGELOG.md was the only conflict — both entries kept under one ## Unreleased, TARIFF-10's first. No version number picked. All 29 suites green on the merged tree, including TARIFF-10's new test_price.py section 13.
Out of scope, and what moved anyway
The persisted price store still does not back the plan during an outage. FCST-03 exists to stop stale prices being acted on and "recent" is not "current"; serving the store is a different promise and needs its own ticket.
The banner/level not moving, and the Arbitrage row's 30-minute lag, are separately carded and untouched. This change makes the first more truthful, not less: during the first TARIFF_MAX_AGE_S of an outage there is now a real plan behind the Arbitrage row instead of a no-plan refusal, so the row says something true for longer. The no-plan state is entered later by up to 5400 s; the lag itself is unchanged in both directions.
Nothing on the 30 s tick.cycle() still reads two attributes and awaits no fetch; section 8's AST pin is unchanged and green.
A price_provider: off install is bit-for-bit unaffected — its standing plan is never origin == "provider", so keep is false on every refresh it will ever run.
## The defect
`run_tariff_refresh()` promised this in its own docstring:
> ⚠️ **AND A FAILURE DOES NOT CLEAR THE PLAN.** … Zeroing the plan here would replace a coarse tariff with no tariff, which is the opposite of what §11.4 asks for.
It guarded the wrong thing. `load_prices()` states in its own docstring that it **"RETURNS, NEVER RAISES, for every failure a site can actually have"**, because a degradation arriving as a traceback into the planning tick would stop the controller regulating over an expired certificate. So a dead feed is a **return**: the `try` succeeded, `_degrade()` handed back a §11.4 plan, and `self.tariff_plan, self.tariff_at = plan, now` overwrote the good one. The `except Exception` was reachable only by something nobody expected — **a supplier outage, the case the docstring names first, never reached it.**
With `static_profile` unset — the shipped default, and the normal state of a dynamic-tariff site — `_degrade()` returns `TariffPlan(no_data, None, "none")`. **A complete 48-hour plan replaced by nothing.**
Fourth card in one family: FCST-04's M4 (`not ok` only), FCST-05's HIGH (`(JSONDecodeError, OSError)` only), FCST-06 (`not self.ok` only), now `except Exception` only for a path that never raises.
## Reproduction
Driving the shipped chain with **nothing stubbed but the socket** — the transport raises the way aiohttp does, `load_prices()` turns it into a `no_data` read, `_degrade()` into a §11.4 plan, and `run_tariff_refresh()` decides. One good fetch, then a `ClientConnectorError` 1800 s later; `TARIFF_MAX_AGE_S` = 5400 s, so the standing plan is **fresh**.
Before:
```
after a good fetch origin='provider' n_points=48 'dynamic_dayahead from generic_dayahead, 48 h'
after the failed fetch origin='none' n_points=0 'no tariff - generic_dayahead: ClientConnectorError: Cannot connect to host ...'
tariff_at moved: True
did resolve_tariff RAISE? no: it RETURNED TariffPlan(origin='none') has_prices=False
```
After:
```
after the failed fetch origin='provider' n_points=48 'dynamic_dayahead from generic_dayahead, 48 h'
tariff_at moved: False
```
…and the log line the operator gets:
```
tariff refresh degraded (no tariff - generic_dayahead: ClientConnectorError: Cannot connect
to host api.energy-charts.info:443 ssl:default); KEEPING the plan resolved at
2026-09-12 18:03:46+00:00, 1800 s ago. tariff_at is NOT moved, so it retires 5400 s after
that fetch and arbitrage stops with it - a kept plan is never presented as a current one.
```
## The fix
One question, asked once, after the attempt, from the evidence that exists — FCST-06's answer, one module over:
```python
standing = self.fresh_tariff_plan(now)
keep = (not plan.from_feed
and standing is not None and standing.from_feed)
```
`TariffPlan.from_feed` (new, on the class that owns `origin`'s vocabulary) answers *did this resolution observe the configured feed*. `fresh_tariff_plan()` is **reused, not transcribed** — it is already the one place the age is judged, and section 8b pins that reuse on the AST.
### The §11.4 static fallback — the judgement this card had to make
**A fallback does not replace a fresh provider plan**, although it carries real prices. Every point `_degrade()` builds is `provisional=True`, so `firm_points()` is empty, `arbitrage()` refuses and `cheap_now()` is False — **everything a consumer may act on is already off** — while a day-ahead price for a given hour does not change once published, so a plan fetched 30 minutes ago is exactly as true about 19:00 as one fetched now. The fallback is less actionable and no more current; it takes over when the observation retires, which is what §11.4 is for.
**And the rule is deliberately not symmetrical.** A standing plan that is *itself* a degradation **is** replaced by the next one, and its timestamp moves — otherwise an outage would freeze the operator's static profile at the window it was first generated for, and a `price_provider: off` install would flip between kept and re-derived every 90 minutes for no behavioural gain.
Each of the three terms is the only false one in some check (MUTATION.md habit 17).
### `tariff_at` does not move on a kept plan
It is the moment of the fetch that produced the plan standing, and `fresh_tariff_plan()` judges the age from it. Re-stamping it would be the refresh asserting the feed answered when it did not — the *"freeze at its last value and look current forever"* hazard the same docstring names — and would leave `TARIFF_MAX_AGE_S` **exactly as dead as it was before this card**. Leaving it alone is what makes the bound govern, and it is the whole of *"distinguishable from a freshly-fetched one"*.
## Acceptance criteria
- [x] A returned degradation does not replace a plan still within `TARIFF_MAX_AGE_S`
- [x] A kept plan still ages out (driven at ±the bound) and is distinguishable — `tariff_at` unmoved, plus a `WARNING` naming the failure, the fetch kept, its age and its retirement
- [x] `TARIFF_MAX_AGE_S` now governs: it is the serve-stale window, consulted on every refresh
- [x] Docstring and behaviour agree; `DOCS.md` and the stale *"so the bound rarely matters"* clause updated with it
- [x] A check that drives a **returned degradation**, not a raised exception (section 8b — a check that patched `resolve_tariff` to raise would have passed against the defect)
- [x] **29/29 test files green**
## Mutations — eleven run, eleven red, no survivors
**Re-driven on the merged tree**, not carried over from before TARIFF-10 landed: the surrounding code changed, so a red against the old base is not evidence about the new one.
Purged before every run; reverts fingerprinted after, not only before (habit 18); harness backup under `mktemp`.
| # | mutation | Δ | named check that went red |
|---|---|---|---|
| M1 | guard removed entirely (the original defect restored) | +10 B | *a RETURNED degradation does not replace a plan still inside TARIFF_MAX_AGE_S* (+3 more) |
| M2 | `not plan.from_feed` → `not plan.has_prices` | +1 B | *a §11.4 static FALLBACK does not replace a fresh provider plan either* |
| M3 | `standing.from_feed` term dropped | −23 B | *a standing plan that is ITSELF a §11.4 fallback IS replaced by the next one* |
| M4 | `fresh_tariff_plan(now)` → `self.tariff_plan` | −11 B | *a plan that has aged PAST 5400 s IS replaced*; *run_tariff_refresh() asks fresh_tariff_plan()* |
| M5 | `tariff_at` re-stamped on a kept plan | +41 B | *…and `tariff_at` did NOT move* (+1 more) |
| M6 | `from_feed` → `origin != "none"` | −4 B | *from_feed is true of exactly the observation* (+2 more) |
| M7 | `from_feed` → `has_prices` | −10 B | *from_feed is true of exactly the observation* (+2 more) |
| M8 | `not plan.from_feed` → `True` | −14 B | *when the feed answers again the observation replaces the kept plan* |
| M9 | M3 + M4 **together** (TEST-16 masking) | −34 B | 3 checks red |
| M10 | `standing is not None` term dropped | −49 B | *a degradation with NO plan standing at all is stored* (+1 more) |
| M11 | guard made conditional on `arbitrage.enabled(self.o)` (the TARIFF-10 seam) | +86 B | 5 checks red, incl. *run_tariff_refresh() READS no trading option* |
## Merged after TARIFF-10 — what the wider gate changed, and what it did not
TARIFF-10 changed **which installs run this task**; this card changed **what the task does on a failed fetch**. Git auto-merged the two hunks in `main.py`, which is exactly when the interaction needs reading. **Nothing in the reasoning changed**, and the two newly reachable configurations are now *driven* in section 8b rather than reasoned about:
- **A provider configured with trading disarmed** — the case this guard was designed for. It needs no second fixture, because **nothing on this path reads `arbitrage_enabled` at all**, and that is asserted rather than assumed. ⚠️ Two drafts of that check were **red against a correct tree**: the first matched the docstring paragraph that *says* `arbitrage_enabled` is not read, the second matched the log line's "arbitrage, where it is armed". A substring test over a function grades its prose as much as its behaviour (habit 4, third costume), so the check now scans the AST's symbols and its non-docstring string constants. A fixture that set the option and asserted *no* difference would have been the other defect — comparing a thing against itself.
- **`price_provider: off` + a `static_profile`** — the config TARIFF-10's `or` protects. It resolves to the *configured* product (`origin: "static"`), so `standing.from_feed` is false on every refresh and the guard never latches; driven twice in a row, because a guard that latched there would freeze a static site's plan at its first window for the life of the install. M3 is red on that check too.
Two more facts settle the rest: the keep decision is **strictly downstream** of `resolve_tariff()`, so the §8.3 carry into `price_history` — TARIFF-10's whole point — is unaffected either way; and the kept-plan log line said *"arbitrage stops with it"*, which after TARIFF-10 is false on a site that fetches without trading, so it now says *"arbitrage, **where it is armed**, stops with it"*.
`CHANGELOG.md` was the only conflict — both entries kept under one `## Unreleased`, TARIFF-10's first. **No version number picked.** All 29 suites green on the merged tree, including TARIFF-10's new `test_price.py` section 13.
## Out of scope, and what moved anyway
- **The persisted price store still does not back the plan during an outage.** FCST-03 exists to stop stale prices being acted on and "recent" is not "current"; serving the store is a different promise and needs its own ticket.
- **The banner/level not moving, and the Arbitrage row's 30-minute lag**, are separately carded and untouched. This change makes the first *more* truthful, not less: during the first `TARIFF_MAX_AGE_S` of an outage there is now a real plan behind the Arbitrage row instead of a no-plan refusal, so the row says something true for longer. The no-plan state is entered later by up to 5400 s; the lag itself is unchanged in both directions.
- **Nothing on the 30 s tick.** `cycle()` still reads two attributes and awaits no fetch; section 8's AST pin is unchanged and green.
- **A `price_provider: off` install is bit-for-bit unaffected** — its standing plan is never `origin == "provider"`, so `keep` is false on every refresh it will ever run.
Decision record: `goodwe_controller/DECISIONS/TARIFF-12-a-degradation-is-not-a-refresh.md`.
⚠️ Nothing was deployed. No `ha addons update`, no writes to `/addons/`, no contact with 192.168.2.158 or the inverter.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
`run_tariff_refresh()` promised in its own docstring that "a failure does
not clear the plan", and enforced it with `except Exception` - a branch a
supplier outage never reaches. `load_prices()` states the reason in its
own words: it "RETURNS, NEVER RAISES, for every failure a site can
actually have", because a degradation arriving as a traceback into the
planning tick would stop the controller regulating over an expired
certificate. So a dead feed RETURNS a §11.4 plan, the `try` succeeds, and
the assignment below it overwrote the good plan unconditionally. With
`static_profile` unset - the shipped default, and the normal state of a
dynamic-tariff site - `_degrade()` returns `TariffPlan(no_data, None,
"none")`: a complete 48-hour plan replaced by nothing, at the FIRST
failed fetch, ~27 minutes after the last good one. `TARIFF_MAX_AGE_S`
(5400 s) never governed anything and there was no serve-stale window at
all.
Fourth card in one family: FCST-04's M4 (`not ok` only), FCST-05's HIGH
(`(JSONDecodeError, OSError)` only), FCST-06 (`not self.ok` only), and
now `except Exception` only, for a path that never raises.
The question is now asked once, after the attempt, from the evidence
that exists - FCST-06's answer, one module over. The evidence is
`TariffPlan.from_feed` (did this resolution observe the configured feed)
and the shipped `fresh_tariff_plan()`, reused rather than transcribed. A
degradation replaces a standing observation only once that observation
has aged out, so `TARIFF_MAX_AGE_S` is now the serve-stale window it
reads like.
A §11.4 static fallback does not replace a fresh provider plan either,
although it carries real prices: every point `_degrade()` builds is
`provisional=True`, so `firm_points()` is empty, `arbitrage()` refuses
and `cheap_now()` is False - everything a consumer may act on is already
off - while a day-ahead price for a given hour does not change once
published. The rule is deliberately not symmetrical: a standing plan
that is itself a degradation IS replaced, or an outage would freeze the
operator's static profile at the window it was first generated for.
`tariff_at` does not move on a kept plan. It is the moment of the fetch
that produced the plan standing, `fresh_tariff_plan()` judges the age
from it, and an unmoved timestamp is the only thing that tells a kept
plan from a freshly fetched one.
test_arbitrage.py section 8b drives the whole chain with nothing stubbed
but the socket: the transport raises the way aiohttp does, load_prices()
returns no_data, _degrade() builds the §11.4 plan, and the shipped
run_tariff_refresh() decides. A check that patched resolve_tariff to
RAISE would have passed against the defect. Ten mutations, ten reds, no
survivors; 29/29 suites green.
Out of scope and unchanged: the persisted price store still does not
back the plan during an outage (FCST-03), and the status banner and the
Arbitrage row's lag are separately carded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
CHANGELOG.md was the only conflict: both entries belong under the same
`## Unreleased` heading, TARIFF-10's first because it merged first. No
version number picked.
app/main.py auto-merged, and was re-read because of it. TARIFF-10 changed
WHICH installs run `run_tariff_refresh()` (`arbitrage.enabled(opts)` ->
`tariff_refresh_wanted(opts)`); this card changed what that task does on
a failed fetch. Nothing in the keep/replace reasoning changed, and the
two newly reachable configurations are now DRIVEN in test_arbitrage.py
8b rather than reasoned about:
* a provider configured with trading DISARMED is the case the guard
was designed for, and needs no second fixture because nothing on
this path reads `arbitrage_enabled` at all - asserted over the AST's
symbols and non-docstring constants, after two drafts of that check
went red on a CORRECT tree for matching the word "arbitrage" in a
docstring and in a log line;
* `price_provider: off` + a `static_profile` resolves to the
CONFIGURED product (`origin: "static"`), so `standing.from_feed` is
false on every refresh and the guard never latches - driven twice in
a row, because a guard that latched there would freeze a static
site's plan at its first window for the life of the install.
Also: the kept-plan log line said "arbitrage stops with it", which after
TARIFF-10 is false on a site that fetches without trading; it now says
"arbitrage, where it is armed, stops with it". The keep decision is
strictly downstream of `resolve_tariff()`, so the §8.3 carry into
price_history - TARIFF-10's whole point - is unaffected either way.
All 29 suites green on the merged tree, including TARIFF-10's new
test_price.py section 13. All eleven mutations RE-DRIVEN on the merged
tree rather than carried over: eleven red, no survivors, every revert
fingerprinted. M11 is new - making the guard conditional on
`arbitrage.enabled(self.o)` (+86 B) is red on five named checks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin
merged commit a6e64fb66e into release/1.02026-09-12 21:39:24 +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.
The defect
run_tariff_refresh()promised this in its own docstring:It guarded the wrong thing.
load_prices()states in its own docstring that it "RETURNS, NEVER RAISES, for every failure a site can actually have", because a degradation arriving as a traceback into the planning tick would stop the controller regulating over an expired certificate. So a dead feed is a return: thetrysucceeded,_degrade()handed back a §11.4 plan, andself.tariff_plan, self.tariff_at = plan, nowoverwrote the good one. Theexcept Exceptionwas reachable only by something nobody expected — a supplier outage, the case the docstring names first, never reached it.With
static_profileunset — the shipped default, and the normal state of a dynamic-tariff site —_degrade()returnsTariffPlan(no_data, None, "none"). A complete 48-hour plan replaced by nothing.Fourth card in one family: FCST-04's M4 (
not okonly), FCST-05's HIGH ((JSONDecodeError, OSError)only), FCST-06 (not self.okonly), nowexcept Exceptiononly for a path that never raises.Reproduction
Driving the shipped chain with nothing stubbed but the socket — the transport raises the way aiohttp does,
load_prices()turns it into ano_dataread,_degrade()into a §11.4 plan, andrun_tariff_refresh()decides. One good fetch, then aClientConnectorError1800 s later;TARIFF_MAX_AGE_S= 5400 s, so the standing plan is fresh.Before:
After:
…and the log line the operator gets:
The fix
One question, asked once, after the attempt, from the evidence that exists — FCST-06's answer, one module over:
TariffPlan.from_feed(new, on the class that ownsorigin's vocabulary) answers did this resolution observe the configured feed.fresh_tariff_plan()is reused, not transcribed — it is already the one place the age is judged, and section 8b pins that reuse on the AST.The §11.4 static fallback — the judgement this card had to make
A fallback does not replace a fresh provider plan, although it carries real prices. Every point
_degrade()builds isprovisional=True, sofirm_points()is empty,arbitrage()refuses andcheap_now()is False — everything a consumer may act on is already off — while a day-ahead price for a given hour does not change once published, so a plan fetched 30 minutes ago is exactly as true about 19:00 as one fetched now. The fallback is less actionable and no more current; it takes over when the observation retires, which is what §11.4 is for.And the rule is deliberately not symmetrical. A standing plan that is itself a degradation is replaced by the next one, and its timestamp moves — otherwise an outage would freeze the operator's static profile at the window it was first generated for, and a
price_provider: offinstall would flip between kept and re-derived every 90 minutes for no behavioural gain.Each of the three terms is the only false one in some check (MUTATION.md habit 17).
tariff_atdoes not move on a kept planIt is the moment of the fetch that produced the plan standing, and
fresh_tariff_plan()judges the age from it. Re-stamping it would be the refresh asserting the feed answered when it did not — the "freeze at its last value and look current forever" hazard the same docstring names — and would leaveTARIFF_MAX_AGE_Sexactly as dead as it was before this card. Leaving it alone is what makes the bound govern, and it is the whole of "distinguishable from a freshly-fetched one".Acceptance criteria
TARIFF_MAX_AGE_Stariff_atunmoved, plus aWARNINGnaming the failure, the fetch kept, its age and its retirementTARIFF_MAX_AGE_Snow governs: it is the serve-stale window, consulted on every refreshDOCS.mdand the stale "so the bound rarely matters" clause updated with itresolve_tariffto raise would have passed against the defect)Mutations — eleven run, eleven red, no survivors
Re-driven on the merged tree, not carried over from before TARIFF-10 landed: the surrounding code changed, so a red against the old base is not evidence about the new one.
Purged before every run; reverts fingerprinted after, not only before (habit 18); harness backup under
mktemp.not plan.from_feed→not plan.has_pricesstanding.from_feedterm droppedfresh_tariff_plan(now)→self.tariff_plantariff_atre-stamped on a kept plantariff_atdid NOT move (+1 more)from_feed→origin != "none"from_feed→has_pricesnot plan.from_feed→Truestanding is not Noneterm droppedarbitrage.enabled(self.o)(the TARIFF-10 seam)Merged after TARIFF-10 — what the wider gate changed, and what it did not
TARIFF-10 changed which installs run this task; this card changed what the task does on a failed fetch. Git auto-merged the two hunks in
main.py, which is exactly when the interaction needs reading. Nothing in the reasoning changed, and the two newly reachable configurations are now driven in section 8b rather than reasoned about:arbitrage_enabledat all, and that is asserted rather than assumed. ⚠️ Two drafts of that check were red against a correct tree: the first matched the docstring paragraph that saysarbitrage_enabledis not read, the second matched the log line's "arbitrage, where it is armed". A substring test over a function grades its prose as much as its behaviour (habit 4, third costume), so the check now scans the AST's symbols and its non-docstring string constants. A fixture that set the option and asserted no difference would have been the other defect — comparing a thing against itself.price_provider: off+ astatic_profile— the config TARIFF-10'sorprotects. It resolves to the configured product (origin: "static"), sostanding.from_feedis false on every refresh and the guard never latches; driven twice in a row, because a guard that latched there would freeze a static site's plan at its first window for the life of the install. M3 is red on that check too.Two more facts settle the rest: the keep decision is strictly downstream of
resolve_tariff(), so the §8.3 carry intoprice_history— TARIFF-10's whole point — is unaffected either way; and the kept-plan log line said "arbitrage stops with it", which after TARIFF-10 is false on a site that fetches without trading, so it now says "arbitrage, where it is armed, stops with it".CHANGELOG.mdwas the only conflict — both entries kept under one## Unreleased, TARIFF-10's first. No version number picked. All 29 suites green on the merged tree, including TARIFF-10's newtest_price.pysection 13.Out of scope, and what moved anyway
TARIFF_MAX_AGE_Sof an outage there is now a real plan behind the Arbitrage row instead of a no-plan refusal, so the row says something true for longer. The no-plan state is entered later by up to 5400 s; the lag itself is unchanged in both directions.cycle()still reads two attributes and awaits no fetch; section 8's AST pin is unchanged and green.price_provider: offinstall is bit-for-bit unaffected — its standing plan is neverorigin == "provider", sokeepis false on every refresh it will ever run.Decision record:
goodwe_controller/DECISIONS/TARIFF-12-a-degradation-is-not-a-refresh.md.⚠️ Nothing was deployed. No
ha addons update, no writes to/addons/, no contact with 192.168.2.158 or the inverter.🤖 Generated with Claude Code
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
CHANGELOG.md was the only conflict: both entries belong under the same `## Unreleased` heading, TARIFF-10's first because it merged first. No version number picked. app/main.py auto-merged, and was re-read because of it. TARIFF-10 changed WHICH installs run `run_tariff_refresh()` (`arbitrage.enabled(opts)` -> `tariff_refresh_wanted(opts)`); this card changed what that task does on a failed fetch. Nothing in the keep/replace reasoning changed, and the two newly reachable configurations are now DRIVEN in test_arbitrage.py 8b rather than reasoned about: * a provider configured with trading DISARMED is the case the guard was designed for, and needs no second fixture because nothing on this path reads `arbitrage_enabled` at all - asserted over the AST's symbols and non-docstring constants, after two drafts of that check went red on a CORRECT tree for matching the word "arbitrage" in a docstring and in a log line; * `price_provider: off` + a `static_profile` resolves to the CONFIGURED product (`origin: "static"`), so `standing.from_feed` is false on every refresh and the guard never latches - driven twice in a row, because a guard that latched there would freeze a static site's plan at its first window for the life of the install. Also: the kept-plan log line said "arbitrage stops with it", which after TARIFF-10 is false on a site that fetches without trading; it now says "arbitrage, where it is armed, stops with it". The keep decision is strictly downstream of `resolve_tariff()`, so the §8.3 carry into price_history - TARIFF-10's whole point - is unaffected either way. All 29 suites green on the merged tree, including TARIFF-10's new test_price.py section 13. All eleven mutations RE-DRIVEN on the merged tree rather than carried over: eleven red, no survivors, every revert fingerprinted. M11 is new - making the guard conditional on `arbitrage.enabled(self.o)` (+86 B) is red on five named checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU