47e99a37bc2dedfb4e36b95d4a80e1a2cf1156ee
10
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
63dda4b0d3 |
MAINT-20 part 1 review: restore three lost lines, rewrap ragged comments
Comment, docstring and DECISIONS only (same-code script OK; python -OO bytecode now identical for all six files). - peakguard.charge_room_w: one-line docstring + SAFETY-125 pointer (cut whole). - peakguard.shaping_suspended: keep 'the one place this conjunction is written; do not inline it'. - price_forecast module note: an hour with no price and no yesterday is left out, never filled (test_price.py 14d cites the module note for it). - Refill comment blocks the cut left ragged; DECISIONS pointers on one line. - FCST-04 note: _yesterday_of(), not _previous_day(); FCST-03 note: the 49 stronger cases are why the rule is a real change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC |
||
|
|
c432d4e419 |
MAINT-20 part 1: price_forecast.py comments and docstrings cut
Co-Authored-By: Claude <noreply@anthropic.com> |
||
|
|
e0b578756f |
UX-10 review: the line as logged gets the red-alarm shape, clamp lines at 30 words, plain HA 400
Part 6 checked the quoted reason alone, so title + reason + tails logged as 4+ sentences. It now also checks the whole line (at most 3 sentences, none over 30 words); 25 lines failed and are rewritten (one-sentence wrappers, two-sentence circulation and peak reasons, STALE_READING's fixed limits on a DEBUG line). The clamp lines for one inverter are pinned at 30 words with OUTPUT_CLAMP and the numbers kept. Home Assistant's bare '400: Bad Request' while it loads becomes one plain line; still WARNING, throttled, a failed write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
f1a660881c |
UX-10: two INFO lines read better on TEST-23 (hours count, maintenance history)
"11 of 48 hours use yesterday's prices", and the maintenance-history line no longer says "maintenance history" twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
23a3a3f5b5 |
UX-10: the last plain-language leftovers: log lines, two table rows, 'Why: Other', red at 0 s
- test_messages.py part 6: every dynamic part of a WARNING+ line is classified as data or as a built text, and every built text is produced by the real code and checked. This is the gap the 2d scan had: it counted `%s` as one word, so reasons built by loop_clamp_note() and friends were never seen. - Quoted reasons rewritten to STYLE.md (red-alarm shape, codes kept): OUTPUT_CLAMP, METER_FROZEN, CIRCULATING_POWER, STALE_READING, the peak guard (codes now PEAK_GUARD*), the migration report, price feed, price history and gate reasons. Plurals via messages.times()/count(). - Top table: Price history "96 prices over 24 hours for 26 Sep", Tariff "one every 15 minutes or every hour", "not final yet" dropped. - Levels and noise: per-cycle shadow line DEBUG, shadow on/off INFO, aiohttp access log only at log level debug, settings warnings once per start, one plain line per Home Assistant outage (hass.Outage). - INFO jargon and error texts: no "§8.3", ISO times, "banked", "keys ARE", "NOT_OBSERVED", "did NOT land", "AND". - Display bugs: WHY_WINNER covers every winner and safety reason (main.why_key(), part 7 finds them in the code); the meter row is amber "waiting for the first reading" inside its limit after a start; the missing-reading age is counted when the page asks. - STYLE.md and DECISIONS/UX-08-short-notifications.md (UX-10 section). No change to safety behaviour, alarm levels, notification ids, triggers, cadence or the banner cascade. One row colour changes (meter at start), justified in DECISIONS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
610725bed9 |
TARIFF-11: the plan's account of itself, corrected and put on the page
Four defects of one shape - the add-on's report of its own tariff plan was wrong, always in the direction that flattered it - and one check, which is the deliverable. 1. `TariffPlan.words` counted POINTS and called them HOURS. The live add-on logged `dynamic_dayahead from generic_dayahead, 132 h` for 132 quarter-hourly points = 33 hours. Belgium moved to a 15-minute MTU on 2025-10-01, so this was wrong every time it was printed, and NOT by a constant factor: a 40-hour soak measured 1.79x .. 5.33x, because §8.3's carried points are hourly while the firm ones are quarter-hourly. `words` now reports both quantities, plus the firm/carried split and the spacing, in FCST-04's *Price history* wording so the two rows can be compared. 2. §8.3's firm-pair refusal and the flat-tariff refusal did the same, and they reach an operator through `self.arb.explain()`. This is exactly the point<->hour conflation TARIFF-14 removed from `at()`, left standing in the prose. `price_forecast.py`'s §8.3 carry-forward log line carried the third instance - a carried-HOUR count over a POINT count - and DOCS quotes it verbatim, so it is corrected here too. 3. The plan reached no HTTP surface at all. `rows["Tariff"]` was `static_status()`'s 30 s-tick sentence and never saw the provider plan - it read 'no static profile' at all 802 soak samples on a site whose feed was answering. It now carries the plan's shape. ⚠️ The two sources are kept distinguishable: the plan half is stamped with its own age, measured from `tariff_at`, because it can be up to TARIFF_MAX_AGE_S (5400 s) old. FCST-04 pins that row to DOCS as a pair; DOCS moves in this commit. 4. `main.py`'s "if and only if" was false. `cycle()` returns at the input hold before `self.arb = None`, so an armed site read `off (arbitrage_enabled)` - naming an option that is on - and, after one completed cycle, showed a decision up to 15 s stale as a current one. The row now reads the OPTION and separates three facts; `cycle_at` and `arb_at` make "this cycle reached no decision" an observation. THE DELIVERABLE IS THE CHECK. All four survived 29 green suites because nothing graded the strings a human is shown. `test_price.py` section 12 renders through the shipped `status_payload()`, parses each number out of its OWN SLOT and compares it against a figure computed from the fixture's construction parameters - not from the property the row was built out of (habit 4), and not as a substring of a sentence the same file wrote (habit 14). The fixture set is four plan shapes and the check computes and prints the field pairs no fixture separates. The *Arbitrage* row's states are driven through real cycles with a sensor answering None and graded by INEQUALITY. No behaviour changed: no threshold, refusal rung, claim or setpoint moved. MUTATIONS: 16, no survivors, one at +0 B (the risky delta) and red. Two were found by the battery INSIDE THIS CARD'S OWN CHECK: a -190 B survivor whose fixture varied two things at once (habit 4), and a mutation whose red was a TRACEBACK in an f-string operand and therefore named nothing (FCST-06). Both closed; the table is on the PR. All 29 test files green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
f620f7cf80 |
TARIFF-07: three plugins that never fetched a price are withdrawn, and a site configured on one is told so
checks / unit (pull_request) Successful in 7m12s
engie_dynamic, totalenergies_dynamic and eneco_eddy are removed from PROVIDERS and from every surface that listed them. They did not stop working - they never worked. Re-verified by hand on 2026-09-13 before deleting anything: 404 with Kong's "no Route matched with those values"; a TLS handshake that fails because api.totalenergies.be is absent from the certificate's SAN list; 404 with an empty body. All three are also structurally impossible - the coefficients are per-contract and behind a customer login, there is no regulator index, and "Eddy" is not an Eneco product at all. ⚠️ BACKWARD-INCOMPATIBLE. A config legal on the current version is refused on the next. That is the point of the change: §11.4 turned every failed fetch into a silent fall back, so a site on one of these ids has been running on static_profile - or on nothing - since the day it was set, with nothing on its status page ever differing from a working provider that never found a cheap hour. validate.py now refuses a retired id with a message naming the id, the endpoint, what that endpoint answers, which tariff has really been in force, and what to set instead. No version number is picked here; that depends on a fact about the live box. ⚠️ config.yaml's price_provider enum deliberately KEEPS all three ids. The Supervisor validates the options file against that enum before the add-on starts, so removing them would make an existing config schema-invalid and the refusal above would never be reached on exactly the sites that need it. PROVIDERS is two; the enum stays six; test_price.py asserts the schema offers exactly PROVIDERS | RETIRED_PROVIDERS | {"off"}, derived rather than typed, and register() refuses to re-register a retired id. PriceProduct.REALTIME, _WIDE, Uncertainty.REALTIME_PRODUCT and WIDEN_FACTOR's 2.5 are KEPT with no shipped declarer - it is the AC2 seam, and a reviewer finding a mechanism with zero callers would reasonably delete it. Recorded in DECISIONS/TARIFF-07-withdrawing-three-plugins.md and pinned by a check. False claims corrected: every "Belgian imbalance product" claim about eneco_eddy (DOCS.md, price_forecast.py, price_history.py, static_tariff.py, FCST-04, CHANGELOG - the two released entries annotated in place rather than rewritten); the quarter-hourly examples now cite Belgian day-ahead, which really does settle per quarter since the 15-minute MTU; and export_price_eur_kwh's "no shipped supplier publishes a dynamic injection curve", which is false - Eneco publishes 0,1 x BELPEX-H - 1,188 and ENGIE (B x eSpot_15) - A, same two-coefficient shape with its own coefficients and its own VAT treatment, so the scalar is a simplification and not the market. DOCS.md's provider table is two rows and now says honestly what a Belgian dynamic customer should do today, including that generic_dayahead cannot express B x spot + A and that price_markup_eur_kwh: float(0,1) could not hold a negative offset. The formula provider is referenced as planned, not built. _points_from_json() went with the plugins it served: it read a shape no feed anybody here has ever reached publishes. The one place it survives is a negative stimulus, named row_shape_body(). Nine mutations driven, all red; table in the decision record. 29/29 suites green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
375a1e29d2 |
FCST-05: one byte stops stopping the add-on, and the write path gets the read path's standard
checks / unit (pull_request) Successful in 7m10s
HIGH - a non-UTF-8 byte in /data/price_history.json stopped the add-on
from starting. `_load()` caught `(json.JSONDecodeError, OSError)`.
`UnicodeDecodeError` is `ValueError -> UnicodeError -> UnicodeDecodeError`:
neither of those, and `issubclass(UnicodeDecodeError, OSError)` is False. So
it escaped `_load()` -> `_open()` -> `PriceHistory.__init__`, which
`Controller.__init__` does not guard - breaking `_load()`'s own stated
contract, "a corrupt cache must not stop the controller". The handler now
names the ValueError FAMILY (the file's content) beside OSError (its bytes),
because a third subclass nobody has thought of is how this arrived the first
time. The suite could not see it: its "unreadable file" fixture writes
"{ this is not json", which is valid UTF-8 and fails one step later, in
`json`. A fixture holding a genuinely undecodable byte is now beside it,
with the difference between the two asserted as a precondition.
MEDIUM - a malformed `start` under the day the feed is writing to killed the
tariff plan permanently. `_remember()` merged with `{q["start"]: q ...}` and
sorted on `q["start"]`, trusting a value read off /data to be a hashable,
sortable string. An int, null, a dict or a list raised TypeError out of
`carry()` -> `resolve_tariff()` -> `run_tariff_refresh`, which catches
Exception - so the add-on stayed UP while every refresh raised forever, the
plan retiring after TARIFF_MAX_AGE_S with arbitrage and the cheap window off
for good, and no self-heal because `_remember()` raised before `_save()`.
The read path was properly defensive throughout, which is why `yesterday()`
survived and the existing fixture passed - it files its malformed rows under
a day the feed is NOT writing to. Both paths now share one validator,
`_point_of()`. A row that is not a point is dropped, reported, and written
back out clean.
...and the status row said the store was healthy while the plan was dead:
`words` read '2026-01-15 (1 h)'. `_load()` now counts the rows inside a kept
record as well as the record itself, so the store stops reporting plain
health from the moment it is opened; the note names what was FOUND at open
and what was DROPPED by the refresh, and clears once the file is rewritten.
...and the merge key is the validated INSTANT rather than its spelling,
which is what rule 4 already said. The lexicographic sort beside the string
key also put the second 02:00 of the 25-hour October day (+01:00) before the
first (+02:00); stored order only, since `_parse_points()` re-sorts.
LOW - the status row counted POINTS and printed them under the HOUR's name:
'2026-01-13 (96 h)' for a 24-hour day on the quarter-hourly feed this add-on
already supports. It is now '96 points over 24 h' - two facts, each under
its own unit.
LOW - two stated reasons were false. `_audit`'s "WHY IT IS STRICTER" claimed
the rework refused the same published point emitted twice and a carried
point sharing a published hour, "which the old count never looked at". A
differential over the whole domain the audit can express finds ZERO
divergence in both directions: the old `seen` count already refused the
first (one point twice is one hour twice) and the old IDENTITY rule already
refused the second. The reworked audit is EQUAL to FCST-03's, not stronger;
the only behavioural change is the intended relaxation, and it stays. The
differential is committed as a check rather than left as prose.
...and the audit's real limit is named: it does not check COMPLETENESS. Fed
the decimation defect FCST-04 was raised for it ACCEPTS, and it accepts an
empty output. So `_audit` is not what stops that recurring - the count and
identity checks are, and the text now points at them. Not made complete:
`published` is the whole read while the fill emits only [since, until), so
the rule as stated is wrong, and building it takes the 1/12/48 h horizon
checks red on a healthy feed. Measured, not assumed.
12 mutations, purged tree, every one RED, no survivors. All 29 test files
green. Nothing deployed; no option added; config.yaml untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
b59f5ce31b |
FCST-04: the carry-forward stops decimating a quarter-hourly feed, and a broken cache stops stopping the add-on
checks / unit (pull_request) Successful in 7m22s
Rework of the FCST-04 branch against review. Every finding below was proven by a runnable script before it was touched and re-run after; every check added or repaired was driven red by a mutation, `__pycache__` purged first (MUTATION.md, TEST-15). 29/29 test files green. ⚠️ H1 - A SUB-HOURLY FEED WAS DECIMATED TO ONE POINT AN HOUR, AND THE SPREAD WENT WITH IT. `fill_from_yesterday()` keyed the published set as `{_utc_hour(p.start): p for p in r.points}` - LAST POINT OF THE HOUR WINS, the other three dropped. `eneco_eddy` is the Belgian imbalance product and settles per QUARTER, and `_validate_raw()` accepts any strictly-increasing spacing, so this is a shape the product supports today. Measured on a 96-point quarter-hourly feed - ALL FIRM, FULLY PUBLISHED, nothing to carry: the plan reached the optimiser as 15 points, the arbitrage spread fell from 0.143 to 0.014 EUR/kWh, and the cheapest "hour" became a `:45` point whose `at()` window runs into the next one. Not the windowing effect this card is about; a feed losing 84 % of its own prints, with no flag anywhere reporting it. The fill now decides only WHICH WHOLE HOURS NEED CARRYING: every published point inside the window passes through BY IDENTITY, and a carried point is added only for a whole hour with no published coverage at all. Same feed, same fixture: 60 of 60 firm points kept, spread 0.134 over the hours still ahead. ⚠️ AND `_audit()` GOT STRICTER, NOT LOOSER - that was the condition on doing it at all. The old post-condition refused any repeated `_utc_hour`, a count that cannot express four points in one hour. Two rules replace it and SUBSUME it: a published point must BE one of that hour's published points by identity and may be emitted only once; a carried point may stand only in an hour absent from the published set, must be flagged True, and may not repeat. So the duplicated carried hour it existed to catch still raises, the same published point emitted twice now raises, and a carried point sharing an hour with a real print now raises too - which the count never looked at. Three mutations, three reds. ⚠️ H2 - A DAY RECORD THAT WAS NOT A RECORD STOPPED THE ADD-ON FROM STARTING. `_load()` validated `version` and that `days` is a dict, never that each VALUE is one. `{"version": 1, "days": {"2026-01-14": "oops"}}` loaded as ok=True, last_error=None, and then `rec.get(...)` raised AttributeError out of `words`, `yesterday()` and `resolve_tariff()` alike. `PriceHistory(...)` and `.words` are both unguarded in `Controller.__init__`, so the controller did not come up - and it never self-healed, because `_remember()` raised on the same record before it could reach `_save()`. That falsifies the CHANGELOG's "A BROKEN CACHE IS NOT A BROKEN CONTROLLER" in the one direction that matters. The asymmetry that produced it is visible in the file: `_parse_points()` is carefully defensive about every field of a POINT and `_load()` was not defensive about the record holding them. Unusable records are now dropped and REPORTED (`words` says how many and why), and `words` is total by construction as a second line. M3 - `yesterday()` WAS OFF BY ONE DAY FOR 00:00-00:59 AFTER SPRING-FORWARD. `brussels()` renders at the FIXED offset for that instant, so `brussels(stamp) - timedelta(days=1)` subtracts 24 ABSOLUTE hours - and the Brussels day before the March transition is 23 hours long. At 2026-03-30T00:30+02:00 it returned 2026-03-28's prices as "yesterday", or `no_data` naming a date that is not yesterday. The arithmetic is now on the local DATE, which has no offset to lose, and both transitions have a check - the module's headline claim had none. M4 - WITH NO /data THE STORE STILL FILLED, IN MEMORY. `_save()` short-circuited on `not ok` while `_remember()` populated `self._days` anyway: `words` said "unavailable" and `yesterday()` answered A SERIES that is gone on the next restart. The module docstring rejects exactly that ("a yesterday that silently forgets") as its reason for not calling makedirs, so the code now matches the docstring rather than contradicting it. M5 - THE PRUNE MUTATION SURVIVED BECAUSE THE STIMULUS WAS DEGENERATE. §8's clock-jump check seeded ONE day, so `if len(self._days) <= KEEP_DAYS: return` fired and the body it is named after never ran: pruning by arithmetic against the clock - which empties the store on exactly the morning the fill is for - passed the whole 29-file suite. The fixture is now written to a file and reopened (`_load()` does not prune, which is how a store legitimately holds more than the cap) with KEEP_DAYS + 2 days, and the mutant is red. M6 - THE AST ORDERING CHECK COULD NOT CATCH A REVERSAL, AND TWO DOCUMENTS CITED IT AS A GUARANTEE. `_calls.index("_remember") < _calls.index("yesterday")` grades `ast.walk` BFS DEPTH: `self._remember(...)` is a statement and `self.yesterday(...)` is an ARGUMENT of the fill, one level deeper, so `_remember` came out first whichever statement was written first - shipped and reversed both scored True. It is now line numbers AND, the half that matters, the behaviour: a feed carrying yesterday alongside today into an EMPTY store must fill the whole horizon; reversed, the lookup finds nothing and the plan is only the published hours. Reversing the two statements is red on both. L14/L9 - KEEP_DAYS = 3 IS THE STEADY STATE EXACTLY, so a newest-N cap has no slack: a feed publishing to D+2, or ONE stray point in the wrong year - which sorts LAST and can never age out - pushes yesterday out for good. `_prune()` now PINS the two days a lookup can actually ask for on top of the cap. Pinning is additive, so it can only RETAIN and the WITNESS-03 property (nothing is deleted by clock arithmetic) is preserved by construction; `stamp` stops being a dead parameter; the store is bounded at KEEP_DAYS + 2. L8 - the horizon end was not floored while `since` was, so a 48 h request made at any instant off the hour returned 49 points. §1's count was green only because its NOW was exactly :00:00, a shape production never has; the fixture now sits at :17:43. L10 - a failed `_save()` leaked its temp file; at 48 writes a day a persistent write failure leaked 48 inodes a day onto the volume that was already the problem. L15 - `{"version": 1.0}` passed, since `1.0 == 1`. L16 - the `_remember` tree walk scanned `app/*.py` only and matched a bare string; it now parses `app/`, `tools/`, `scenarios/` and `estop/` and looks for the NAME, so a comment does not fail it and a call in `tools/` cannot pass unseen. CLAIM-ACCURACY DEFECTS, WHICH THIS REPO TREATS AS REAL: * L7. DOCS said "*Tariff* on the status page reads `provisional` through the morning". FALSE - `tariff_words` is assigned only from the HA price-entity branch and from `static_status()`, both on the 30 s tick, and the plan's own sentence reaches `_LOG.info`. The doc now says what an operator can see (the Price history row, the Arbitrage row's own refusal sentence, the refresh log line) and a check pins the PAIRING - wiring the plan into the row is fine, correcting the doc is fine, doing one without the other is red. * L11. "fills EVERY unpublished hour" is conditional: a partial yesterday leaves the local hours it never held out of the series (§11.4 for those hours, and `at()` raises rather than answering). Correct behaviour, overclaimed wording, and untested - all three fixed. * L12. DECISIONS §1 leaned on `TariffPlan.cheap_now()`, which has NO PRODUCTION CALLER on the provider plan (`self.cheap` comes from `static_status()`, which builds its own). The conclusion holds on `arbitrage()`'s firm-pair refusal alone, which does reach control; the evidence is now stated at the reach it has. * L13. §1's "passed through by identity" check compared `eur_per_kwh` between two SEPARATE `resolve_tariff()` calls, where `is` could never have held. The value comparison is kept and now says what it grades; identity is graded on a read the file constructs and still holds. * L17. `PriceHistory.words` and the new status row had no check at all - and `words` is one of the two things H2 crashed. Both now have one. * L18. main.py's inline note carried the "widens what the optimiser can SEE, never what it may act on" guarantee without the windowing caveat that the CHANGELOG, DOCS and `resolve_tariff()`'s docstring all carry. It is the one place a maintainer reads it unqualified. EVIDENCE. All eight reviewer proofs re-run: p1/p1b return 29 March, p4 keeps 60 of 60 firm points at spread 0.134, p5 starts and reports, p6 D leaves no temp, p6 E refuses 1.0, p6 F records nothing, p7 returns 48 for 48. p2's script still scores True/True because it re-implements the OLD algorithm - the reversal is now driven as a mutation instead, and is red. p3's script always showed the shipped code correct; what was wrong was the fixture, and its mutant is now red. Twenty mutations driven, no survivors, tree fingerprinted and `__pycache__` purged before each: decimation (2 suites red), audit x3, unfloored `until` (2), unvalidated day records (7 checks), instant-subtraction yesterday, in-memory fill on a volume-less store, clock-arithmetic prune, cap without the pin, leaked temp, loose schema type, empty `words` (5), reversed `carry()` (2), a `_remember` leak in tools/ spelled two ways, a neighbour-filling fallback, a copy instead of identity (2), the DOCS claim in both directions, and the caveat removed from main.py. Baseline and the unmutated tree both green. No option added, config.yaml untouched, and the CHANGELOG entry still picks no version: PKG-07's rule, and only the deployer knows whether 0.9.0's image has been pulled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
dd40c56ab4 |
FCST-03: yesterday's prices, flagged as yesterday's, and unable to move anything
checks / unit (pull_request) Successful in 2m43s
§8.3 fills the hours a day-ahead feed has not published yet with yesterday's profile and flags them provisional, and forbids any irreversible action on such a price. The whole risk of this ticket is one line - a carried point and a published point are three fields and a bool, so `provisional=y.provisional` copies yesterday's False onto today's guess and produces stale data presented as current. That is SAFETY-18's frozen-but-fresh meter one layer up. So the flag is never passed through. Carried points come from a constructor with no provisional parameter; published points pass through BY IDENTITY, so no copy exists that could lose one; an audit re-derives the expected flag for every emitted point and raises rather than repairing; and the check walks the module's own AST so the pass-through idiom cannot come back looking ordinary. A published point may itself be provisional (eneco_eddy's imbalance print is), so the rule for a published hour is "whatever the feed said", never False. The fill keys on the Brussels wall clock rather than t-24h, so the 23- and 25-hour days are right; a late publication rebuilds the series, moving the number and the flag together. AC2 and AC3 landed on TARIFF-03's existing verdict surface rather than beside it. Refusal 3 became "fewer than two FIRM hours" - refusing on "any point is provisional" would have switched arbitrage off every morning on every dynamic site - and refusal 5 is new: the spread must clear a threshold widened by the widest active uncertainty source (max, never a product). The permitting variant now refuses to be constructed around a provisional point at all, and the cheap-window flag became per-hour for the same reason. The irreversible action is a value carrying the hours it commits, derived from maintenance.py's own DRAIN_TIMEOUT_MIN; the gate blocks a maintenance drain whose window reaches into a carried hour, and the phase machine stays idle. It is the price-justified entry point only - a gate that blocked on no_data would stop the monthly cycle on every default install. Zero new config options; TEST-14's signature scan ported to cover both Gate's and Arbitrage's constructors, closing _Refused.reason's open hole. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |