# 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 make `ok` mean something new, it would write to `/data` on 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 `/data` filled 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._days` **never 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.** `/data` is 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")` and `last_error`, reported by both `words` and `yesterday()`'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.