The Invariant and the Schema Disagreed
The architecture document said it plainly: saved articles are permanent, and the retention function is the only thing that deletes an article. The retention function was written to match — careful, explicit, sparing exactly the right rows. The schema said something else. The article table's reference to its source carried ON DELETE CASCADE.
Nothing connected the two facts until someone asked what happens when a shelf is deleted. Deleting a shelf deletes the sources no other shelf holds — reasonable, and something the settings screen does on the reader's behalf. The cascade then took those sources' articles with them. Saved ones included. The careful retention function was never called.
Prove It Before You Fix It
The temptation with a bug like this is to change the constraint immediately, because the fix is obvious. Reproducing it first is worth the ten minutes, for a reason that is not about confidence in the diagnosis: a reproduction tells you the size of the problem, and the size decides what else has to change.
On a temporary copy of the database, one source deletion emptied the archived shelf. That result is what surfaced the second half of the bug — because once articles could survive without a source, the query that renders them had to survive that too.
The Second Half: An Inner Join Hides the Rescue
Changing the constraint to SET NULL means a surviving article can have no source at all. The shelf query joined articles to sources to get the outlet label — an inner join, which drops rows with no match. So the articles rescued from the cascade would have disappeared from every shelf anyway: the same failure, one layer up, and much harder to notice because the rows would still be sitting in the table looking fine.
The join had to become a left join with a placeholder label. Constraint and query are one change. Fixing either alone leaves the promise broken while making it look repaired, which is worse than the original bug.
Detect From the World, Not From a Flag
SQLite cannot alter a constraint in place, so the repair is the documented table rebuild — create, copy, drop, rename. The interesting decision is how a running installation knows it needs one. A schema-version flag would work until someone's flag disagreed with their actual schema. Reading the live foreign-key definition and rebuilding only if the cascade is genuinely present makes the migration idempotent and self-verifying: it asks the database what is true instead of asking a number what should be true.