Files
goodwe-addon/goodwe_controller/DECISIONS/TARIFF-12-a-degradation-is-not-a-refresh.md
T
Claude f5ea39784d
checks / unit (pull_request) Successful in 7m16s
Merge release/1.0 (TARIFF-10) into TARIFF-12
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
2026-09-12 21:31:27 +02:00

253 lines
13 KiB
Markdown

# TARIFF-12 — a returned degradation is not a refresh
Three decisions. The first is what the ticket put up for judgement; the second
and third are what fall out of it and would otherwise be decided by accident.
---
## 0. What was actually wrong, stated once
`run_tariff_refresh()`'s 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.
The guarantee was real. The condition it was enforced on was not:
```python
try:
plan = await resolve_tariff(...)
self.tariff_plan, self.tariff_at = plan, now # unconditional
except Exception as err:
_LOG.warning("tariff refresh failed (%s); keeping the plan ...")
```
`load_prices()` says 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 supplier outage — the case the docstring names first —
is a **return**: the `try` succeeds, `_degrade()` hands back a §11.4 plan, and
the good plan is overwritten. The `except` is reachable only by something
nobody expected, and nothing in the shipped path produces it.
This is the 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. **A guarantee enforced on
the condition someone thought of, rather than the condition that occurs.**
Reproduced before the fix, driving the shipped chain with nothing stubbed but
the socket — 48 h horizon, `static_profile` empty, one good fetch then a
`ClientConnectorError` 1800 s later, `TARIFF_MAX_AGE_S` = 5400 s:
```
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: ...'
tariff_at moved: True
```
and after it:
```
after the failed fetch origin='provider' n_points=48 'dynamic_dayahead from generic_dayahead, 48 h'
tariff_at moved: False
```
---
## 1. The question is asked ONCE, after the attempt, from the evidence that exists
FCST-06's answer, one module over, and for the same reason: **the enumeration
of failure conditions cannot be completed.** There is no pre-check that a feed
will answer which is not itself a guess, and adding one would be a second
symptom test beside the `except`. So the predicate is an **outcome**, read off
the object the attempt produced:
```python
standing = self.fresh_tariff_plan(now)
keep = (not plan.from_feed
and standing is not None and standing.from_feed)
```
Two facts, both of which exist by the time the question is asked:
* **`plan.from_feed`** — did *this* resolution observe the configured provider?
`TariffPlan.origin` already names the four ways a plan is obtained; this
collapses them to the one question a caller holding a previous plan has to
ask. It lives on `TariffPlan` rather than as an `origin == "provider"` at the
call site for `fresh_tariff_plan()`'s own stated reason — *a check has to be
able to assert on the shipped expression rather than on a transcription that
drifts* — and because `origin`'s vocabulary belongs to `static_tariff.py`.
* **`fresh_tariff_plan(now)`** — is a standing observation still current. It
already existed, it is already the one place the age is judged, and it is
**reused, not transcribed**. `test_arbitrage.py` 8b pins that reuse on the
AST; a copy of the comparison behaves identically until the day the two
drift, which is a claim about the code's shape, so a shape check is what
grades it (FCST-06's precedent for the one source check among driven ones).
### What was NOT done
* **Not `except Exception` widened, or a second `if` beside it.** That is the
defect's own shape — a place where the rule is spelt twice, so a fifth cause
needs a fifth branch. The `except` stays, relabelled *"not the outage path"*,
keeps the plan on the same terms, and is now honestly the
something-nobody-expected branch.
* **Not the persisted price store as a cache.** Out of scope by the product
owner's call, and it is the right call: FCST-03 exists precisely to stop
stale prices being acted on, and "recent" is not "current". Keeping the last
**fetched** plan until it ages out is a different promise from serving the
store, and that one needs its own ticket.
* **Not a `no_data`-only guard.** `not plan.has_prices` is the tempting
spelling and it is wrong — see decision 2. It leaves the §11.4 fallback free
to replace a fresh observation, and it is a 1-byte edit from the shipped
line, which is why it is in the mutation table rather than in a comment.
---
## 2. A §11.4 static fallback does NOT replace a fresh provider plan
The ticket asked this directly, because the fallback is *not* nothing: it is
the operator's own configured profile, with real prices, for the requested
window. The answer is still no, and the reason is not that it is worse data —
it is that **nothing may be acted on it, and it is no more current.**
* Every point `_degrade()` builds carries `provisional=True`. So
`firm_points()` is empty, `arbitrage()` finds fewer than two firm hours and
refuses, and `cheap_now()` is False. Both of the two things `TariffPlan`
exposes for a consumer to act on are already off.
* A day-ahead price for a given hour **does not change once published**. A plan
fetched 30 minutes ago is exactly as true about 19:00 as one fetched now; at
most `TARIFF_MAX_AGE_S` of its horizon has elapsed. The observation is not
stale in any sense the fallback is fresher than.
So replacing the observation with the fallback trades a plan with firm hours
for a plan with none, and buys no currency with it. The fallback takes over
when the observation **retires** — which is exactly the row §11.4 describes,
arrived at when there is no longer an observation to prefer.
### And the rule is deliberately NOT symmetrical
`standing.from_feed` is a term in its own right. A standing plan that is itself
a degradation **is** replaced by the next degradation, and its timestamp moves.
Without that term:
* an outage on a static-profile site would freeze the operator's profile at the
window it was first generated for, and the guard would be *"never overwrite"*
wearing a bound's name;
* and on a `price_provider: off` install — the shipped default — the plan would
flip between kept and re-derived every `TARIFF_MAX_AGE_S`, for no behavioural
gain, changing which reason the status page prints on a two-hour cycle.
Each of the three terms is the only false one in some check in 8b, which is
MUTATION.md habit 17's test for whether a suite can tell an `AND` from an `OR`.
---
## 3. `tariff_at` does not move on a kept plan
The docstring's other half is that a plan must not *"freeze at its last value
and look current forever"*. `tariff_at` is what makes that work: it is the
moment of the fetch that produced the plan standing, and `fresh_tariff_plan()`
judges the age from it.
Re-stamping it on a kept plan would be this task asserting the feed answered
when it did not. The plan would be re-certified as current at every refresh, an
outage of any length would never retire it, and `TARIFF_MAX_AGE_S` would be
**exactly as dead as it was before this card** — reachable only by the refresh
task itself dying. Leaving it alone is what makes the bound govern: the plan is
served for the age bound after its own fetch, and no longer.
It is also the whole of **"distinguishable from a freshly-fetched one"**. A
kept plan and a fetched plan are the same object shape; what separates them is
that one's timestamp advanced and the other's did not, and every consumer that
asks how old the plan is already reads that number. The kept case additionally
logs at WARNING, naming the degradation's own reason, the fetch it is keeping,
its age, and the deadline it retires at.
⚠️ **What this does not give the operator, and why it is not here.** The status
page has no row for "the last refresh failed" — the banner/level not moving
when the plan is lost is separately carded, and the ticket rules it out of
scope. This card's change makes that surface *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; it is not removed,
and the 30-minute lag reported on that row is untouched by anything here.
---
## 4. Merged after TARIFF-10 — what the wider gate changed, and what it did not
TARIFF-10 landed on `release/1.0` while this card was open. It changed **which
installs run this task** — `if arbitrage.enabled(opts)` became
`tariff_refresh_wanted(opts)`, i.e. `provider_configured(opts) or
arbitrage.enabled(opts)` — while this card changed **what the task does on a
failed fetch**. Git auto-merged the two hunks, which is exactly when the
interaction needs reading rather than trusting.
**Nothing in the decisions above changed.** Two configurations are newly
reachable, and both are now driven in `test_arbitrage.py` 8b rather than
reasoned about:
* **A provider configured with trading DISARMED** — TARIFF-10's headline case.
This is precisely the configuration the guard was designed for: the plans are
`origin == "provider"`, and a dead feed must not delete one that is still
inside `TARIFF_MAX_AGE_S`. It needs no second fixture, because **nothing on
this path reads `arbitrage_enabled` at all**, and that is asserted rather
than assumed — over the AST's symbols and the non-docstring string constants,
because two earlier 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,
which is habit 4 in a third costume. 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 configuration TARIFF-10's
`or` exists to protect, and the one where a latching guard would do real harm.
`resolve_tariff()` returns `static_plan(provisional=False)`, whose origin is
`"static"`: the CONFIGURED product, not a §11.4 row. So `standing.from_feed`
is false on every refresh, `keep` is false on every refresh, and the plan is
regenerated for the current window every 30 minutes exactly as before this
card. **Driven, twice in a row**, because a guard that latched here would
freeze a static site's plan at its first window for the life of the install —
and mutation M3, which drops the `standing.from_feed` term, is red on that
check as well as on the fallback one.
Two further facts settle the rest of the interaction:
* **This decision is strictly downstream of every side effect.** The §8.3 carry
into `price_history` happens inside `resolve_tariff()`, on the `series`
branch, before `keep` is computed — so which of two plans is kept can neither
add nor lose a recorded day, and TARIFF-10's *Price history* row is
unaffected either way.
* **The kept-plan log line was reworded.** It 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".
All eleven mutations were **re-driven on the merged tree** rather than carried
over — the surrounding code changed, so a red against the old base is not
evidence about the new one. Eleven red, no survivors, every revert
fingerprinted. The added member is M11: making the guard conditional on
`arbitrage.enabled(self.o)` (+86 B), red on five named checks.
⚠️ **And one process note that belongs in this file.** This card's mutation
harness was written to a shared scratchpad path and was **overwritten mid-run
by another agent's harness for a different card**, pointing at a different
worktree. That is MUTATION.md habit 18's second bullet almost verbatim. It was
caught because the harness fingerprints the tree after every revert and the
runs are what proved this worktree was never left mutated; the harness now
lives at a card-specific path. **A harness that edits source must not share a
name, a directory or a backup path with another one.**
---
## What this does not change
* **Nothing on the 30 s tick.** `cycle()` still reads two attributes and awaits
no fetch; the AST pin in `test_arbitrage.py` section 8 is unchanged and green.
* **Nothing about the prices themselves.** No series is invented, extended,
re-stamped or carried. The only question this card answers is *which of two
plans the controller holds*.
* **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, and the assignment happens exactly as it did before.