# TARIFF-07 — three plugins that never fetched a price, and the one mechanism they leave behind **Status: DECIDED 2026-09-13 — withdraw `engie_dynamic`, `totalenergies_dynamic` and `eneco_eddy`. Keep `PriceProduct.REALTIME` and the widening machinery. Refuse a retired id by name rather than aliasing it.** Filed with TARIFF-07. Follows [[TARIFF-08]], which found the same class of defect one layer down — a parser graded against its own guess — and left the question of these three open. --- ## 1. The endpoints, verified rather than taken on report The card said the three endpoints were dead. They were re-checked by hand on **2026-09-13** before anything was deleted, because "the endpoint is dead" is exactly the kind of claim this repository keeps finding to be second-hand: | id | endpoint | observed | |---|---|---| | `engie_dynamic` | `https://api.engie.be/dynamic/be` | **HTTP 404**, body `{"message":"no Route matched with those values","request_id":"..."}` — a Kong gateway with no route configured | | `totalenergies_dynamic` | `https://api.totalenergies.be/prices/dynamic` | **TLS failure**. The handshake completes and the server presents `CN=pge-apigee-dev.direct-energie.com`; `api.totalenergies.be` is not in the SAN list, so the connection is closed before any request is sent | | `eneco_eddy` | `https://api.eneco.be/eddy/prices` | **HTTP 404**, `content-length: 1`, `server: Microsoft-IIS/10.0` — an empty body from a live host that does not serve this path | All three failed. Had any one of them answered, this card would have stopped and asked, because an endpoint that answers changes the decision from "withdraw" to "fix the parser". ## 2. Why this is not "unfinished", and why no replacement can be written An unfinished plugin has a right endpoint and a wrong parser. These have neither, and the reason is structural: * **ENGIE's dynamic price is `(B × eSpot_15) − A`, and `B` and `A` are PER-CONTRACT.** They live behind the customer's klantenzone login. No unauthenticated public URL can return *a given customer's* price, so the endpoint this plugin wanted cannot exist for anybody to build. * **There is no regulator index to read the coefficients from.** CREG's tariff-card pages 404; VREG was unreachable. Every coefficient found in research came from a supplier's own PDF, which is a document, not a feed. * **"Eddy" is not an Eneco product at all.** Eneco's dynamic product is **"Zon & Wind Dynamisch"**, and it is indexed to **Belpex/EPEX day-ahead** — not to imbalance. The plugin was wrong about the product, the product type and the URL simultaneously. So the three were not withdrawn for being broken. They were withdrawn for describing tariffs that no public endpoint can serve. ## 3. What a site configured on one of these has actually been running ⚠️ **This is the half that decides what the migration is.** §11.4 turns a failed fetch into a silent fall back: static profile, plan provisional, no arbitrage. That is the *correct* degradation, and it says nothing at all about the cause. Every fetch these three ever made raised `SourceUnavailable`. Therefore a site configured on one of them has been running on `static_profile` — **or on nothing at all, where no profile was set** — since the day the id was entered, and **nothing on its status page has ever differed from a working provider that simply never found a cheap hour**. The migration is therefore **not** "keep their prices working". There were no prices. It is *"tell them their prices never worked"*, which is why the answer is a refusal and not a fallback. ### Why not a silent alias to `generic_dayahead` It is the friendlier-looking option and it is wrong. These published **retail** prices; `generic_dayahead` reads **wholesale**. Aliasing needs the site's own `B` and `A`, which §2 has just established nobody has. An alias would have to **invent the numbers a battery is priced against** — the same defect as a parser graded against its own guess, one layer further from anything that reports. The scalar `price_markup_eur_kwh` cannot stand in for `B` either: it is an addend, not a multiplier, and it is `float(0,1)` so it cannot even hold a negative `A`. ## 4. ⚠️ The `config.yaml` schema is deliberately WIDER than the registry `price_provider` is a `list(off|…)` enum, and the Supervisor validates the options file against it **before the add-on starts**. So the obvious edit — delete the three ids from the enum along with the classes — has a consequence that runs exactly opposite to this card's purpose: ``` operator's config says price_provider: eneco_eddy | +-- id NOT in the schema enum | +-- Supervisor rejects the options file | +-- add-on never starts | +-- validate.py never runs | +-- the named refusal is NEVER SEEN ``` The operator gets an opaque `expected one of ...` from a layer they did not configure, on **exactly the sites the withdrawal exists to inform**. **Decision: keep all three ids in the schema enum.** `PROVIDERS` shrinks to two; the enum stays at six. The cost is that the dropdown still offers three dead ids to a *new* operator — and that is the cheaper failure, because choosing one is refused at the next startup **by name**, with the endpoint, the observation and the alternative, which is strictly more than a schema rejection would say. Both translations label them withdrawn in the option description. `app/price.py`'s `RETIRED_PROVIDERS` is the list. `test_price.py` section 1 asserts the schema offers exactly `PROVIDERS | RETIRED_PROVIDERS | {"off"}` — derived, not typed — and `register()` refuses to re-register a retired id, so the registry and the retired table cannot both claim one and the refusal cannot be taken away by an `@register` somebody adds back. ## 5. ⚠️ `PriceProduct.REALTIME` and the widening path are KEPT with no shipped declarer `eneco_eddy` was the **sole** declarer of `PriceProduct.REALTIME`. Deleting it orphans: * `_WIDE = frozenset({PriceProduct.REALTIME})` * `Uncertainty.REALTIME_PRODUCT` * `WIDEN_FACTOR[Uncertainty.REALTIME_PRODUCT] = 2.5` **They stay.** The reasoning, stated here so the next reader finds intent rather than an apparent dead branch: 1. A reviewer finding a mechanism with **zero callers** would reasonably delete it. That is good instinct applied to a missing fact, and this file is the fact. 2. It is the **AC2 seam**. The whole point of `capabilities().product` is that a new supplier is a new class and nothing else: declare `REALTIME`, get the wide regime, edit no framework code. Deleting the enum member would mean the first genuine imbalance product has to **rebuild** that seam — and rebuilding it is precisely the "editing the optimiser to add a supplier" that AC2 forbids. 3. It costs nothing. Two enum members and a dict entry. **⚠️ NO SHIPPED PLUGIN DECLARES `PriceProduct.REALTIME` UNTIL ONE DOES.** Both shipped plugins (`generic_dayahead`, `manual_csv`) are day-ahead. §9.2's row 2 is unoccupied. That is not the same as it having no mechanism, and the difference is what this section exists to record. It is **pinned, not merely documented**. `test_price.py` section 5 asserts: * that **no shipped plugin** declares `REALTIME` — so if one ever does, the check goes red and the right fix is to **edit this record**, not to delete the seam; * that the widening still works **from a declaration alone** — `volatility_regime`, `uncertainty_of` and `widen_factor` are graded on a `ProviderCapabilities` the test file builds; * and section 10 registers `_NewSupplier`, a class defined **outside `price.py`** that declares `REALTIME` and is asserted to land in the wide regime. That class is now the only live declarer in the tree, which makes the seam something this suite exercises end to end rather than a constant it reads. ## 6. What else went, and one thing that did not `_points_from_json()` went with the three. It read a `{"data": [{...}]}` row-per-point document that **no feed anybody here has ever reached publishes** — the shape was invented alongside these plugins, and the only bodies ever fed to it were fixtures written from the same guess. TARIFF-08 found that loop on the response side for `generic_dayahead`; this is the rest of it. `needs_credentials` went too: the three were its only setters and nothing read it. The shape survives in **one** place and in one direction: `row_shape_body()` in `test_price.py`, feeding `generic_dayahead` so that section 9 grades a row document as a **failure** for it rather than a second dialect it might sniff. It is named for what it is. ⚠️ **One check had to be rebuilt rather than deleted, and saying so matters.** `test_price.py` section 4b proved that `code_leaks()`'s docstring strip is load-bearing by finding a shipped docstring that a raw text scan would false-positive on. The docstring that did that was **`engie_dynamic`'s** — it spelled `price_markup_eur_kwh` in full. With it gone, no shipped docstring spells a full framework key, so the check would have **passed on a tree with the strip deleted**: a check that had quietly stopped being one, which is the third failure mode in `MUTATION.md`. The stimulus is now constructed in the test file (`_DocumentedPlugin`), and the shipped set is asserted clean separately. ## 7. Mutations driven Every check added or rebuilt here was driven red. Harness: `/home/homelab/.cache/t07-mut/`, which rebuilds the tree from the worktree and purges `__pycache__` per `MUTATION.md`'s rule. | | mutation | Δ | red on | |---|---|---|---| | M1 | delete the retired branch from `_price_problems()` | −2785 B | 7 checks in `test_price.py` | | M2 | move the retired branch **after** the unknown-id branch | **+0 B** | the same 7 | | M3 | make `been_running` constant (drop the empty-`static_profile` case) | −212 B | 2 | | M4 | drop the "WHAT TO DO" half of the refusal | −874 B | 1 | | M5 | `RETIRED_PROVIDERS = {}` | −1099 B | 5, then a traceback | | M6 | drop the three ids from `config.yaml`'s enum — §4's trap | −47 B | 2 | | M7 | delete `register()`'s retired guard | −1101 B | 2, then a traceback | | M8 | `_WIDE = frozenset()` | −23 B | 3 + 4 across two suites | | M9 | `WIDEN_FACTOR[REALTIME_PRODUCT]` 2.5 → 1.5 | **+0 B** | 1 + 1 | ⚠️ **M2 and M9 have a byte delta of zero**, which `MUTATION.md` names as the risky case. The harness rebuilds the tree from source on every run and purges `__pycache__`, so there is no inherited cache to read; and stale bytecode manufactures **survivors**, never reds, so a red from a `+0 B` mutation is not at risk from that defect. M2's grep confirmed the branch had moved on disk. ⚠️ **M5 and M7's red counts are LOWER BOUNDS.** Both runs ended in a traceback with checks after it ungraded — FCST-06's rule: read the output, not the count. M7's traceback is itself informative: with the guard gone the re-registration **succeeds**, `PROVIDERS` is polluted with a class that has no fixture, and the suite dies downstream. That is the failure the guard prevents, arriving loudly. ## 8. What this card did not do * **It did not pick a version number.** The change is backward-incompatible — a config legal on the current version is refused on the next — and the CHANGELOG says so prominently, in the shape PKG-07 established for `slew_w`. Which number it belongs to depends on whether the current image has already been built and pulled, which is a fact about the live box that this branch cannot observe. Whoever deploys decides. * **It did not implement the formula provider.** `B × spot + A` with per-site coefficients is the real answer for a Belgian dynamic customer and is a separate card. `DOCS.md` references it as planned and is explicit about what `generic_dayahead` can and cannot express in the meantime — in particular that it cannot represent a negative offset, and that `price_markup_eur_kwh: float(0,1)` could not hold one anyway. * **It did not rewrite released CHANGELOG history.** The two historical entries that called `eneco_eddy` an imbalance product are annotated in place with a correction naming this card, rather than silently edited. The record of what the project believed is part of the record.