TARIFF-02 review: close the three other doors to the markup, and stop overclaiming
checks / unit (pull_request) Successful in 2m34s

Review of PR #16 wrote a double-counting plugin four ways. PluginOptions
blocked one. The one that decided it touched nothing but
PRICE_OPTION_DEFAULTS - a public dict in the module every plugin must
already import for register() - so "the number is not in scope" was
simply false, and an absolute guarantee a reviewer trusts is worse than
a stated limit, because they stop checking.

register() now refuses, AT IMPORT, any plugin whose own code mentions a
framework-owned name: FRAMEWORK_KEYS plus PRICE_OPTION_DEFAULTS,
price_option and _o. code_leaks() parses the class and drops every
docstring node before searching, because every plugin is REQUIRED to
name its sourced components in prose - engie_dynamic says "markup" in
its docstring and a raw text scan false-positives on the documentation
the same file mandates. A check that fires on required documentation is
a check somebody deletes. test_price.py asserts that a raw scan really
would have false-positived, so the strip is proven load-bearing rather
than asserted.

It fails OPEN if inspect.getsource() cannot find the .py, which the
add-on image always can. Refusing to start a battery controller because
a static analysis could not run would trade a real outage for a
hypothetical accounting error - so instead the checks assert source was
found for all five shipped plugins, and a build that lost it is a red
check rather than a quietly disarmed guard.

All three reachable attacks are written out as plugins in the suite and
each must be refused at registration. The cost is measured, not
asserted: honest wholesale 0.188, every attack 0.219 - one markup high,
every hour, invisible in every log this add-on writes.

The guarantee is now stated at its true size everywhere it appears: the
framework adds the complement exactly once, GIVEN A CORRECT sources
DECLARATION. A plugin that declares {ENERGY} while its feed is all-in
still doubles while touching no framework name, and no code can catch
that - the declaration is an unverifiable claim about somebody else's
tariff. Measured in the suite (0.276) so the limit is a number rather
than a caveat. The docstring/declaration check verifies that a plugin's
two claims AGREE, not that either is true; that now says so.

The three surviving mutations:

M3 - _NoData's reason taking a default. This is the identical survivor
left open on RIG-01's _Unread.cause: the shape was borrowed from
sim/riglock.py including its blind spot, which is what borrowing a shape
does if nobody re-runs the mutations against the copy. Closed by
SIGNATURE - no constructor or handler of an outcome type may carry a
default - so the next one is red without anyone writing a new case.

M5 - the ENERGY-required guard was untested. Its consequence is that
adds becomes {ENERGY, TAXES} and the emitted price silently omits the
energy it is pricing.

M7 - deleting the TLS refusal went green AND the check named for it
still passed, because the old parser stripped a fixed len("https://")
and mangled http://api.energy-charts.info/p into the host
pi.energy-charts.info, which then failed the ALLOW-LIST. An assertion
that does not test what it names, inside a security check. Fixed on both
sides: the scheme is parsed as a scheme, so an http URL now resolves to
its real and declared host and only the TLS rule can refuse it; and the
check asserts the REASON. Fixing it surfaced M7b - the no-scheme guard
was survivable the same way - found by mutating my own fix, and closed
the same way.

Also carried forward: rule 7's refusal is correct forever but its stated
reason ("the provider decides nothing") expires the moment TARIFF-03 or
OPT-04 lands. test_price.py section 12 now greps app/ by AST for a
load_prices consumer and goes red when one appears, telling whoever
lands it to update the message in the same commit. The first draft of
that tripwire used a substring and matched the comment in validate.py
explaining the tripwire - the same false-positive class as the plugin
docstrings, hit twice in one ticket, and fixed the same way.

Documented rather than coded around: vars(read).get("points", []) and
bool(read) both still get past the absence type, and neither is worth
code - defining __bool__ would turn a line that reads like a guard into
one that IS a guard by accident, which is how .get(k, default) got here
in the first place.

Stated plainly in DOCS/CHANGELOG/PR: every fixture is synthetic, so the
five feed URLs and JSON shapes are verified against a belief about what
those suppliers publish rather than against the feeds. A wrong shape
degrades to no-price-data, not to a wrong price.

Review's own DST verification recorded: 149,040 hourly instants,
2020-2036, zero mismatches against zoneinfo. The residual is political,
not arithmetic, and the docstring now says so.

24 mutations driven through the suite, 0 survivors. Whole add-on suite
green. No deleted lines outside this ticket's own files; main.py still
untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
This commit is contained in:
2026-08-30 19:35:36 +02:00
co-authored by Claude Opus 5
parent 852fc7ced6
commit e102cde841
5 changed files with 531 additions and 37 deletions
+338 -16
View File
@@ -36,6 +36,7 @@ TWO RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
agree exactly.
"""
import ast
import asyncio
import inspect
import io
@@ -46,13 +47,16 @@ import sys
from datetime import datetime, timedelta, timezone
from app.price import (
ALL_IN, Absence, Component, FrameworkOption, MAX_PLAUSIBLE_EUR_KWH,
ALL_IN, Absence, BANNED_IN_PLUGIN_CODE, Component, FrameworkOption,
MAX_PLAUSIBLE_EUR_KWH, code_leaks,
PRICE_OPTION_DEFAULTS, PROVIDERS, PluginOptions, PricePoint, PriceProduct,
PriceProvider, PriceRead, ProviderCapabilities, RawPoint, SourceUnavailable,
arbitrage_allowed, assemble, brussels, brussels_offset, check_egress,
price_option,
export_price_eur_kwh, load_prices, plan_is_provisional, register,
volatility_regime,
)
from app import price as _price
from app.validate import validate_startup_config
fails = []
@@ -68,6 +72,34 @@ def run(coro):
return asyncio.run(coro)
def _reason(fn):
"""The message fn() raised, so a check can assert WHICH rule refused.
`raises(...)` only says that something refused. M7 survived because two
different rules could refuse the same URL and the check could not tell them
apart - so where a check is named for a specific rule, it asserts on the
reason instead.
"""
try:
fn()
except Exception as e: # noqa: BLE001
return str(e)
return ""
def raises(fn, exc):
"""True if fn() raises exactly `exc`. A DIFFERENT exception is False, not
True - otherwise "it was refused" and "it broke on the way to being
refused" would grade the same, which is how M7 hid."""
try:
fn()
except exc:
return True
except Exception:
return False
return False
class _Raised:
"""A value a check wanted, whose computation raised.
@@ -500,6 +532,147 @@ check("...and the wholesale plugin, which really does use them, does not",
"generic_dayahead" not in _warned)
# --------------------------------------------------------------------------- #
print()
print("4b. a plugin cannot reach the markup BY ANY ROUTE (review of PR #16)")
# WHY THIS SECTION EXISTS. The first version of this ticket claimed the markup
# was "not in scope" for a plugin because `PluginOptions` refused it. Review
# wrote a double-counting plugin three more ways, one of them touching nothing
# but `PRICE_OPTION_DEFAULTS` - a public dict in the module every plugin must
# already import for `register`. One closed door in a room with three is not a
# guarantee. Honest wholesale baseline 0.188; each attack lands 0.219.
_banned = set(BANNED_IN_PLUGIN_CODE)
check(f"the banned set covers the framework keys AND the three public routes "
f"review actually used {sorted(_banned)}",
_banned == set(_price.FRAMEWORK_KEYS) | {"PRICE_OPTION_DEFAULTS",
"price_option", "_o"})
# code_leaks() fails OPEN when inspect.getsource() cannot find the .py - the
# right trade for a battery controller, and a silently disarmed guard if nobody
# checks. THIS is the check that makes CI prove the guard is armed.
_srclen = derived(lambda: {pid: len(inspect.getsource(cls))
for pid, cls in PROVIDERS.items()})
check(f"the scan really read every shipped plugin's source, so its fail-open "
f"path is not silently in use {_srclen}",
isinstance(_srclen, dict) and len(_srclen) == 5
and all(n > 200 for n in _srclen.values()))
_dirty = derived(lambda: {pid: code_leaks(cls)
for pid, cls in PROVIDERS.items()
if code_leaks(cls)})
check(f"...and all five shipped plugins are clean {_dirty}", _dirty == {})
# THE DOCSTRING STRIP IS LOAD-BEARING, AND THIS PROVES IT RATHER THAN ASSERTING
# IT. Every plugin is REQUIRED to name its sourced components in prose, so
# engie_dynamic's docstring contains "markup" - a raw text scan false-positives
# on the documentation the same file mandates, and a check that fires on
# required documentation is a check somebody deletes.
_raw_hits = derived(lambda: sorted(
pid for pid, cls in PROVIDERS.items()
if any(b in inspect.getsource(cls) for b in _banned)))
check(f"a RAW text scan would false-positive on the mandated docstrings "
f"{_raw_hits}, which is why code_leaks() strips them before searching",
isinstance(_raw_hits, list) and _raw_hits != [] and _dirty == {})
class _AttackPrivateDict(PriceProvider):
"""A1: reaches into PluginOptions' private dict.
SOURCES: energy
The framework ADDS: markup, taxes
"""
id = "_attack_private_dict"
@classmethod
def capabilities(cls):
return ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.ENERGY}))
async def fetch(self, o, since, until, transport):
return [RawPoint(T0, 0.10 + o._o["price_markup_eur_kwh"], False)]
class _AttackHelper(PriceProvider):
"""A2: calls a public framework helper with the raw options dict.
SOURCES: energy
The framework ADDS: markup, taxes
"""
id = "_attack_helper"
@classmethod
def capabilities(cls):
return ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.ENERGY}))
async def fetch(self, o, since, until, transport):
return [RawPoint(T0, 0.10 + price_option(o._o, "price_markup_eur_kwh"),
False)]
class _AttackPublicConstant(PriceProvider):
"""A2b: touches nothing but a public module constant. THE DECIDING ONE.
SOURCES: energy
The framework ADDS: markup, taxes
"""
id = "_attack_public_constant"
@classmethod
def capabilities(cls):
return ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.ENERGY}))
async def fetch(self, o, since, until, transport):
return [RawPoint(T0,
0.10 + PRICE_OPTION_DEFAULTS["price_markup_eur_kwh"],
False)]
_ATTACKS = {"A1 private dict": _AttackPrivateDict,
"A2 public helper": _AttackHelper,
"A2b public constant": _AttackPublicConstant}
for _name, _cls in _ATTACKS.items():
_found = derived(lambda c=_cls: code_leaks(c))
check(f"{_name}: the scan names what it reached for {_found}",
isinstance(_found, list) and _found != [])
check(f"...and register() REFUSES it at import, not at fetch time",
raises(lambda c=_cls: register(c), FrameworkOption))
check(f"...so it never enters the registry", _cls.id not in PROVIDERS)
# THE COST OF THE DEFECT, MEASURED, so the refusal is not grading itself.
_honest_wholesale = derived(lambda: assemble(
PROVIDERS["generic_dayahead"].capabilities(),
[RawPoint(T0, 0.10, False)], MARKUP, TAXES)[0].eur_per_kwh)
_attacked = derived(lambda: assemble(
_AttackPublicConstant.capabilities(),
[RawPoint(T0, 0.10 + MARKUP, False)], MARKUP, TAXES)[0].eur_per_kwh)
check(f"an unrefused attack would bill {_attacked} against an honest "
f"{_honest_wholesale} - one markup high, every hour, silently",
isinstance(_attacked, float) and isinstance(_honest_wholesale, float)
and abs((_attacked - _honest_wholesale) - MARKUP) < 1e-9)
# WHAT THIS DOES *NOT* CLOSE, AND CANNOT. A plugin that declares {ENERGY} while
# its feed is really all-in comes out one markup high while touching no
# framework name at all. No code can check that: the declaration is the
# plugin's unverifiable claim about somebody else's tariff. Stated here so the
# guarantee is read at its true size - the complement is added exactly once,
# GIVEN A CORRECT DECLARATION - and so a reviewer who trusts an absolute
# guarantee does not stop checking. Measured, not asserted:
_misdeclared = derived(lambda: assemble(
ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.ENERGY})),
[RawPoint(T0, 0.10 + MARKUP + TAXES, False)], MARKUP, TAXES)[0].eur_per_kwh)
check(f"a MIS-DECLARED all-in feed still doubles ({_misdeclared} vs "
f"{_honest_wholesale}) with no framework name touched - the limit of the "
f"guarantee, stated rather than hidden",
isinstance(_misdeclared, float)
and abs((_misdeclared - _honest_wholesale) - (MARKUP + TAXES)) < 1e-9
and code_leaks(_AttackPrivateDict) != [])
# --------------------------------------------------------------------------- #
print()
print("5. product type is declared, not inferred from the name (AC2, trap 2)")
@@ -536,8 +709,6 @@ check("volatility_regime refuses a plugin NAME, which is the AC2 violation "
# plugin id at all - if it does, the sixth supplier is an edit to price.py's
# shared machinery rather than a new class. Derived: build the set of ids that
# actually occur, and assert it is empty.
from app import price as _price # noqa: E402
_framework = [_price.assemble, _price.load_prices, _price.volatility_regime,
_price.arbitrage_allowed, _price.plan_is_provisional,
_price.check_egress, _price._validate_raw, _price.PriceRead,
@@ -606,16 +777,6 @@ check("...and the cheapest hour is computable for the zero series",
derived(lambda: min(p.eur_per_kwh for p in _zero.points)) == 0.0)
def raises(fn, exc):
try:
fn()
except exc:
return True
except Exception:
return False
return False
check("no_data has no .points, so `min(read.points)` cannot invent a free hour",
raises(lambda: _none.points, Absence))
check("...no .product to switch a threshold regime on",
@@ -630,6 +791,54 @@ check("...and it is not iterable, so `for p in read:` cannot be an empty loop",
check("...while its one real field, the reason, is there for an operator",
_none.reason == "the feed timed out")
# M3, INHERITED FROM RIG-01 AND CLOSED HERE. `_NoData.__init__(self,
# reason="no price data")` used to go green: a required field with a DEFAULT is
# the exact idiom this whole type exists to forbid, and it survived because
# every check passed a reason explicitly. It is the identical survivor left
# open on sim/riglock.py's `_Unread.cause` - the shape was borrowed including
# its blind spot, which is what borrowing a shape does if nobody re-runs the
# mutations against the copy.
#
# Checked by SIGNATURE rather than by one call, so any default added to any of
# these constructors later is red without somebody remembering to write a new
# case.
def _defaulted(fn):
"""Parameters of `fn` that carry a default. `self` is skipped."""
try:
ps = list(inspect.signature(fn).parameters.values())
except (TypeError, ValueError) as e: # pragma: no cover
return _Raised(e)
return [q.name for q in ps
if q.name != "self" and q.default is not inspect.Parameter.empty]
_ctors = {"PriceRead.no_data": PriceRead.no_data,
"PriceRead.series": PriceRead.series,
"_NoData.__init__": _price._NoData.__init__,
"_Series.__init__": _price._Series.__init__,
"PriceRead.match": PriceRead.match}
_with_defaults = derived(lambda: {n: d for n, d in
((n, _defaulted(f)) for n, f in _ctors.items())
if d})
check(f"no constructor or handler of an outcome type carries a DEFAULT, so a "
f"reading can never be built out of nothing {_with_defaults}",
_with_defaults == {})
check("...so no_data() with no reason at all is a TypeError at the call site",
raises(lambda: PriceRead.no_data(), TypeError))
check("...and the variant class itself cannot be built bare either",
raises(lambda: _price._NoData(), TypeError))
check("...nor can a series be built without its product or provenance",
raises(lambda: _price._Series([PricePoint(brussels(T0), 0.2, False)]),
TypeError))
# The positive arm, so the four above are not passing because everything raises.
check("...while a fully specified no_data is still perfectly constructible",
PriceRead.no_data("the feed timed out").reason == "the feed timed out")
# `match` must also refuse POSITIONAL handlers - keyword-only is what makes a
# forgotten arm a TypeError rather than a silently swapped one.
check("match() refuses positional handlers, so the two arms cannot be swapped",
raises(lambda: _none.match(lambda r: 1, lambda r: 2), TypeError))
check("an EMPTY series cannot be constructed at all, so 'no prices' can never "
"wear the shape of an answer",
raises(lambda: PriceRead.series([], PriceProduct.DAY_AHEAD, "x", T0),
@@ -828,6 +1037,34 @@ check("...and its canonical starts are 25 distinct strings",
# --------------------------------------------------------------------------- #
print()
print("9. the boundary rejects what cannot be a price, and only that")
# M5. Deleting the `Component.ENERGY not in sources` guard used to go green,
# and the consequence is not cosmetic: `adds` becomes {ENERGY, TAXES},
# `assemble()` never adds an energy component it thinks it is adding, and the
# emitted all-in price silently omits the energy it is supposed to be pricing.
check("capabilities() refuses a feed that declares no ENERGY component",
raises(lambda: ProviderCapabilities(
product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.MARKUP})), ValueError))
check("...including a completely empty sources set",
raises(lambda: ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
sources=frozenset()), ValueError))
check("...and an unknown component is refused rather than ignored",
raises(lambda: ProviderCapabilities(
product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.ENERGY, "grid_fees"})), ValueError))
check("...and a product given as a string is refused at the declaration too",
raises(lambda: ProviderCapabilities(product="dynamic_dayahead",
sources=frozenset({Component.ENERGY})),
ValueError))
# THE CONSEQUENCE, derived across every shipped plugin rather than asserted on
# one: the framework must never be in a position to "add" the energy component,
# because there is nothing to add it from.
_adds_energy = derived(lambda: sorted(
pid for pid, cls in PROVIDERS.items()
if Component.ENERGY in cls.capabilities().adds))
check(f"so no shipped plugin ever asks the framework to invent an energy "
f"price {_adds_energy}", _adds_energy == [])
_caps = PROVIDERS["generic_dayahead"].capabilities()
@@ -867,9 +1104,44 @@ check("a declared https host is allowed",
check("an undeclared host is refused even over TLS",
raises(lambda: check_egress("https://evil.example/p", _g),
SourceUnavailable))
check("plain http is refused",
raises(lambda: check_egress("http://api.energy-charts.info/p", _g),
SourceUnavailable))
# M7, AND THE ASSERTION IS THE FIX, NOT ONLY THE PARSER. Deleting the TLS rule
# used to leave this check GREEN: the old parser stripped a fixed
# len("https://") from the front, so `http://api.energy-charts.info/p` became
# the host `pi.energy-charts.info`, which failed the ALLOW-LIST instead. The
# feed was refused, the check named "plain http is refused" passed, and the
# rule it was named for could be deleted with no consequence - an assertion
# that does not test what it names, inside a security check.
#
# Three checks now, and the first two are what make the third able to fail:
# prove the host is declared over https, prove it parses identically over
# http, and only then assert that http is refused AND that the reason is TLS.
_tls_url = "http://api.energy-charts.info/p"
check("the same host over https is allowed, so it IS on the allow-list",
derived(lambda: check_egress("https://api.energy-charts.info/p", _g))
== "api.energy-charts.info")
check("...and the http form parses to that same declared host, so nothing but "
"the TLS rule can refuse it",
derived(lambda: _tls_url.partition("://")[2].split("/", 1)[0])
== "api.energy-charts.info")
_tls_reason = derived(lambda: _reason(lambda: check_egress(_tls_url, _g)))
check(f"plain http is refused, and refused BY THE TLS RULE - {_tls_reason!r}",
isinstance(_tls_reason, str) and "non-TLS" in _tls_reason)
check("a scheme that is neither is refused the same way, by TLS not by host",
"non-TLS" in str(derived(lambda: _reason(
lambda: check_egress("ftp://api.energy-charts.info/p", _g)))))
# ⚠️ M7b, WHICH I FOUND IN MY OWN FIX FOR M7 AND WHICH IS THE SAME DEFECT ONE
# LAYER DOWN. This check used to read `raises(..., SourceUnavailable)` and
# survived deleting the `if not sep` guard: with no guard,
# "api.energy-charts.info/p".partition("://") leaves scheme = the WHOLE string,
# so the TLS rule refuses it and the check named for the scheme guard passes.
# Refused either way - but the diagnostic an operator gets degrades from "not an
# absolute URL" to "uses 'api.energy-charts.info/p'" as a scheme, which is
# gibberish. Assert the reason, like M7 above.
_noscheme = derived(lambda: _reason(
lambda: check_egress("api.energy-charts.info/p", _g)))
check(f"a URL with no scheme is refused BY THE SCHEME GUARD, with a diagnostic "
f"that names the real problem - {_noscheme!r}",
isinstance(_noscheme, str) and "not an absolute URL" in _noscheme)
check("a userinfo prefix cannot smuggle a declared host past the check",
raises(lambda: check_egress(
"https://api.energy-charts.info@evil.example/p", _g),
@@ -953,6 +1225,56 @@ check("the Home Assistant entities alone still start, so nothing is taken away "
validate_startup_config(vbase(price_now_entity="sensor.now",
price_avg_entity="sensor.avg"),
-5000, 5000) == [])
# ⚠️ A TRIPWIRE ON A REASON, NOT ON A BEHAVIOUR, AND IT IS AIMED AT WHOEVER
# LANDS TARIFF-03 OR OPT-04. Rule 7 refuses "provider + HA entities" and its
# stated reason is that the provider series decides NOTHING while the HA pair
# quietly keeps deciding. The refusal stays correct forever; its EXPLANATION
# expires the moment a consumer appears - at that point both really do decide,
# and it becomes a genuine two-writer conflict rather than a silently-ignored
# one. A future reader would then be trusting a reason that is no longer the
# reason, which is the same defect TEST-06's boundary comment names.
#
# So: assert that nothing in app/ consumes the series yet. When you wire
# load_prices() into the optimiser this goes RED, and the fix is to update the
# refusal message in validate.py IN THE SAME COMMIT - not to delete this check.
_APP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "app")
# ⚠️ AST, NOT A SUBSTRING, AND THE FIRST DRAFT PROVED WHY. A text scan for
# "load_prices" matched the COMMENT in validate.py that explains this very
# tripwire, so the check went red against its own documentation - the same
# false-positive class as the plugin docstrings in section 4b, hit twice in one
# ticket. Comments and docstrings are not in an AST, so a name/import walk sees
# code and only code.
def _imports_or_calls(path, name):
try:
tree = ast.parse(open(path, encoding="utf-8").read())
except (OSError, SyntaxError):
return False
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and any(a.name == name
for a in node.names):
return True
if isinstance(node, ast.Name) and node.id == name:
return True
if isinstance(node, ast.Attribute) and node.attr == name:
return True
return False
_consumers = derived(lambda: sorted(
f for f in os.listdir(_APP)
if f.endswith(".py") and f != "price.py"
and _imports_or_calls(os.path.join(_APP, f), "load_prices")))
check(f"no module consumes load_prices() yet, so rule 7's stated reason - 'the "
f"provider decides nothing' - is still TRUE {_consumers}. When this goes "
f"red, update that message in validate.py in the same commit",
_consumers == [])
# ...and the other half: the registry IS on the startup path, which is what
# makes an unwired seam anchored rather than dead code.
check("...while validate.py does import the registry, so the seam is exercised "
"on every boot",
derived(lambda: "PROVIDERS" in open(
os.path.join(_APP, "validate.py"), encoding="utf-8").read()) is True)
check("manual_csv with no path is refused - the offline fallback with no file "
"is §11.4 reached by accident",
why(validate_startup_config(vbase(price_provider="manual_csv",