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
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.originalready 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 onTariffPlanrather than as anorigin == "provider"at the call site forfresh_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 becauseorigin's vocabulary belongs tostatic_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.py8b 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 Exceptionwidened, or a secondifbeside it. That is the defect's own shape — a place where the rule is spelt twice, so a fifth cause needs a fifth branch. Theexceptstays, 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_pricesis 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 carriesprovisional=True. Sofirm_points()is empty,arbitrage()finds fewer than two firm hours and refuses, andcheap_now()is False. Both of the two thingsTariffPlanexposes 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_Sof 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: offinstall — the shipped default — the plan would flip between kept and re-derived everyTARIFF_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 insideTARIFF_MAX_AGE_S. It needs no second fixture, because nothing on this path readsarbitrage_enabledat 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 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, 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+ astatic_profile— the configuration TARIFF-10'sorexists to protect, and the one where a latching guard would do real harm.resolve_tariff()returnsstatic_plan(provisional=False), whose origin is"static": the CONFIGURED product, not a §11.4 row. Sostanding.from_feedis false on every refresh,keepis 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 thestanding.from_feedterm, 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_historyhappens insideresolve_tariff(), on theseriesbranch, beforekeepis 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 intest_arbitrage.pysection 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: offinstall is bit-for-bit unaffected. Its standing plan is neverorigin == "provider", sokeepis false on every refresh it will ever run, and the assignment happens exactly as it did before.