A plate can be read many times
One plate rarely gets exactly one reading. Dad presses "Read again". He saves a correction, which asks for a re-read. A new rubric version re-reads every plate at the next start. A correction that lands while Pippa is still reading queues another pass. Each of these ends, when it succeeds, in a new reading.recorded event with a fresh reading id.
Nothing is deleted when that happens. Every reading stays in the readings table and in the journal. What moves is a pointer: the reading.recorded handler sets the encounter's reading_id to the new reading. Sizzle's house sign states the rule as an invariant: a reading never edits an encounter. It is a new reading, and links count only for the encounter's current one.
Links remember which reading made them
Every row in links says who made it (source is reading or dad) and, for a reading, which one (reading_id). The primary key is the triple of encounter, class and source, and the projection writes links with INSERT OR REPLACE. Put those together and a re-read can go three ways:
| The re-read... | What happens to the rows |
|---|---|
| names the same dish again | The existing reading row is replaced, now carrying the new reading_id. It still counts. |
| names a different dish | A new row appears for the new dish. The old row stays in the table, still bound to the old reading. |
| meets a link Dad made himself | Dad's row has its own key (source = 'dad'), so it sits beside the reading's row and is never replaced by it. |
One view does the forgetting
The second case leaves a stale row behind on purpose. Deciding which rows count is the job of a view:
current_links keeps a row if Dad made it, or if a reading made it and that reading is still the encounter's current one. Every question that counts plates asks this view: the evidence status of a class, the "In the tree" list on a plate, the plates shown on a class page, the dish chips in the plate list. None of those queries has to remember the rule, so none of them can get it wrong.
Here is what that looks like with generic dishes. A plate is first read as carbonara. Dad writes a correction: it is cacio e pepe. The re-read names cacio e pepe and links it. The carbonara row is still in links, bound to the first reading, and the view no longer returns it. If that was carbonara's only plate, carbonara drops back to inferred, and cacio e pepe now reads as eaten, or as cooked if the plate was marked as cooked at home. No row was updated by hand and no row was deleted.
What a superseded reading leaves behind
The view forgets claims. The tree does not forget proposals. The carbonara class the first reading proposed is still in classes, still a proposal, and any parent edge it gained is still in class_parents, because Sizzle has no event that deletes a class or an edge. That is a reasonable outcome here because both of its labels stay honest: it shows as proposed and as inferred, so it cannot pass for something Dad ate. A reader building the same thing should notice the asymmetry and decide whether orphaned proposals need a cleanup step of their own.
The rubric bump
RUBRIC_VERSION is 2 on origin/main. Changing the reading's voice or schema means bumping it, and the bump is how every old plate gets read again. When the worker starts, it asks the store for stale_readings: plates whose reading finished and whose current reading was made under an older rubric. For each one it records reading.requested with the reason rubric 2, starts the worker task and wakes it.
The encounters themselves stay untouched. No encounter.captured event is rewritten. Each plate gets a new reading under the new rubric, its links rebind to that reading, and the old links fall out of the view without anyone touching them. The engine test plants a reading under RUBRIC_VERSION - 1, restarts the app on the same data, and checks that exactly one new reading was asked for and that the current reading now carries the current rubric.
One edge follows from the query. stale_readings selects only plates whose status is done, so a plate whose last attempt failed is not queued by a bump. It waits for "Read again" like any other failed plate.
Track 5 explains why the mirror these tables live in can be thrown away and rebuilt from the journal. The sqlite-quest's Writes, Transactions, and WAL track covers the conflict side of this, including UPSERT.