price_history.py's docstring states its rule unconditionally - "A store that
cannot persist now records nothing, and both of its answers say the same
thing" - and FCST-04 enforced it against `not self.ok` only, which is one
SYMPTOM: the durable volume is missing. `ok` is about the DIRECTORY, and a
read-only remount and a full disk both leave it True. Reproduced on 5059e28
against two real volumes, a /data bind-mounted read-only and a full tmpfs:
last_error = 'could not write ([Errno 30] Read-only file system)'
words = '2026-01-14 (24 points over 24 h); 2026-01-15 (24 points
over 24 h) [could not write (...)]'
yesterday()= series, 24 points
ON DISK = {}
`words` and `yesterday()` were describing two different stores, and the 24
points were gone on the next restart with nothing saying so - the
silently-forgetting cache that rule exists to refuse, reached by the condition
it did not name. No shipped path reaches it: /data is the Supervisor's durable
volume and this module is its only writer. The defect is that a guarantee the
module states about itself did not hold in one condition.
Route taken: `_remember()` refuses to retain what it could not write, and the
docstring's claim stands as written. It is now ONE derived predicate rather
than two places testing two symptoms. "Can this store persist?" cannot be
answered BEFORE the attempt - a directory that existed and was writable a
moment ago is not a write that completed - so it is answered after it, once,
by the only thing holding the evidence: `_save()` returns whether the file now
holds what memory holds. No volume is one False, an OSError is another.
`_remember()` keeps only what that one answer says was kept, and the
`not self.ok` test in `_remember()` is GONE rather than joined by a second one.
FCST-04's M4 and FCST-05's HIGH were the same shape, so a third cause now needs
no third branch.
What is undone is the MERGE, not the store. The state restored is the last one
that IS on disk, so a site whose volume fills at 14:00 goes on answering the
yesterday it wrote at 13:30 - that record survives a restart, so answering it
is not a promise the store cannot keep. It stops adding to it. The snapshot is
copy.deepcopy: a one-level copy shares the day record the merge rebinds
`points` on, and measured with one, a store holding 12 persisted points
answered 24 after a failed merge.
Not fatal, deliberately: §6 and this module's opening - a broken cache must not
be a broken controller, and an add-on that will not start because /data filled
up is a worse defect than this one. `_save()` reports and returns; the shipped
resolve_tariff() path still gives a provider plan with no §11.4 degradation.
What changed is that the failure is now acted on rather than only logged.
test_price_history.py section 5d drives it on two REAL failures with different
syscalls and different errnos - a directory where the file goes (os.replace,
EISDIR) and a volume moved out from under an open store (mkstemp, ENOENT) -
because the rule is enforced on the outcome and one cause would grade a
coincidence. DOCS.md's "where there is no /data" sentence is widened to the
condition the code now holds to.
MUTATION.md gains what M4 exposed: a mutation whose red is a TRACEBACK reports
however many checks ran before it and grades none after it. M4 first printed
1 FAIL; the true figure is 3. An f-string operand read `len(read.points)`, and
`.points` on a no_data raises `Absence` by design - why()'s rule arriving in
what a check PRINTS rather than in what it asserts. `npts()` is now beside
why(), and a red count is a lower bound until the run is seen to reach the end
of its section.
6 mutations, purged tree, every one RED on named checks, no survivors:
M1 self._days = before -> dict(self._days) +10 B 9 red
M2 _save() OSError handler returns True -1 B 9 red
M3 _save() returns True on `not self.ok` -1 B 3 red
M4 self._days = before -> self._days = {} -4 B 3 red
M5 copy.deepcopy -> dict -9 B 3 red
M6 `if not self.ok: return` back in _remember() +43 B 1 red
All 29 test files green. Nothing deployed; no option added; config.yaml and
app/ behaviour on a healthy /data untouched. No version number picked - that
is the open decision the Unreleased block names.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
6.6 KiB
FCST-06 — "cannot persist" is one question, and it is asked about the outcome
Two decisions. The first is the one the ticket put up for judgement; the second is the one that decides what a store answers after it has failed to write.
1. One derived predicate, not two places testing two symptoms — yes
price_history.py's module docstring states, unconditionally:
⚠️ AND THAT RULE IS CARRIED OUT IN
_remember(), NOT ONLY IN_save(). … A store that cannot persist now records nothing, and both of its answers say the same thing.
It was enforced against not self.ok, and ok is about the directory: it
says /data was there when the store opened. A read-only remount and a full
volume both leave it True. So the second way to be a store that cannot
persist — ok=True, the directory present, _save() failing — was outside the
one condition the rule named, and in that state words printed two full days
beside the write error while yesterday() answered 24 real points, from memory,
until a restart dropped them without a word.
The ticket's question was whether "cannot persist" should be one derived
predicate rather than two places each testing a different symptom. It should,
and the reason is not tidiness — it is that the enumeration cannot be
completed. FCST-04's M4 enforced the rule on the symptom that was in front of
it. FCST-05's HIGH was the same shape — an except clause naming the one
content-failure member somebody had met (json.JSONDecodeError) instead of the
ValueError family — and its fix was to name the family. This is the third instance in
one module, and it is the last place in this file where the rule can be spelt
twice. A fourth cause — an OSError subclass, a filesystem that accepts the
mkstemp and refuses the os.replace, a container whose volume is unmounted
mid-refresh — needs no fourth branch now, because none of them is asked about.
Where the one question can be asked, which is the whole design
"Can this store persist?" cannot be answered before the attempt. A
directory that exists and was writable a moment ago is not a write that
completed; there is no pre-check that is not itself a guess, and adding one
(os.access, a probe write) would be a third symptom test — precisely what
the ticket rules out. So the predicate is an outcome:
_save() -> bool # True only if the file now holds what memory holds
Both ways of not persisting are the same answer from the same call: no durable
volume returns False without touching the disk, an OSError out of the write
returns False after recording why. _remember() reads that one answer and
keeps only what it says was kept. The not self.ok test in _remember() is
gone — not moved, gone — because there is nothing left for a second test to
add, and a check in test_price_history.py 5d asserts its absence from the
source.
⚠️ That last check is deliberately a source check, and habit 4 is why it is allowed to be. The claim "there are not two places testing two symptoms" is a claim about the shape of the code, not about a behaviour — the behaviour is identical whether the rule is enforced once or twice. Every other check in 5d is driven.
What was NOT done, and why
- Not a
can_persist()helper. It would have to answer before the attempt, which is the thing that cannot be done honestly, and it would be a second place to keep in step with the first. - Not a probe write at
_open(). It would makeokmean something new, it would write to/dataon every start, and a volume that is writable at 08:00 and full at 14:00 would still defeat it. A predicate that can go stale is a symptom test wearing a predicate's name. - Not making a failed write fatal. §6, and this module's own opening: a
broken cache must not be a broken controller. An add-on that will not start
because
/datafilled up is a worse defect than the one being fixed. The store degrades; it does not raise._save()reports and returns.
2. The MERGE is undone, not the store
When _save() answers no, _remember() restores the state the read started
from — not an empty one.
The alternative considered was to clear self._days outright, on the argument
that a store which cannot write is a store that should stop answering. It is
wrong, and measurably so: the state being restored is the last one that is on
disk, so answering it is not a promise the store cannot keep. A site whose
volume fills at 14:00 goes on answering the yesterday it wrote at 13:30 —
through the restart, because that record is in the file. What it stops doing is
adding to it.
Clearing would throw away a good yesterday that survives a restart in order to punish a write that failed, and it would make the morning fill worse on exactly the day the operator is already dealing with a full disk.
That gives the file the invariant its rule was always describing, stated once in the class docstring:
self._daysnever holds a point the file does not.
⚠️ Not "mirrors the file" — _usable_days() drops records the file still
holds, and that asymmetry is deliberate. The only direction that can invent a
yesterday is the one named.
⚠️ And the snapshot is copy.deepcopy, not dict(self._days). A one-level
copy shares the day records, and the merge rebinds points on the ones it
touches — so the "restored" store would answer the merge that never reached the
file. Measured: with a shallow copy, a store holding 12 persisted points
answered 24 after a failed merge. It would also be correct only for as long
as nobody makes the merge mutate a points list or a row in place, which is the
same "correct for the conditions someone thought of" this ticket is about. The
store holds at most KEEP_DAYS + 2 records and writes 48 times a day; a copy
that is right by construction is worth more than the allocations.
What this does not change
- No shipped path reaches the defect.
/datais the Supervisor's durable volume and this module is its only writer, so on a real install_save()succeeds and nothing above is on the hot path. The fix is that a guarantee the module states about itself now holds in the condition it did not. - The failure was already visible and still is —
_LOG.error("could not persist price history: %s")andlast_error, reported by bothwordsandyesterday()'s reason. What changed is that it is now acted on. - Nothing about the prices. The series served is the feed's own, every carried point is still flagged provisional, and a broken cache is still a short horizon rather than a §11.4 degradation.