Files
goodwe-addon/goodwe_controller/DECISIONS/TARIFF-12-a-degradation-is-not-a-refresh.md
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

13 KiB

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:

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:

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.