Files
mangaBookmark/docs/adr/0015-finished-is-a-series-fact.md
T
sulthan 77e3f710ec Spec #136: finished becomes a Series fact, Lane gate rewritten on it (#157)
finished_at lands on series (0016 seeds it from the pre-flip finished
bookmarks, then flips those bookmarks to archived), the poll gate and
eligible count read the flag instead of a per-Reader vote, PUT rejects
the finished status like any unknown value, and the web UI drops the
Finished tab, badge and finish button. Novel merge rank is archived >
reading. Per-Reader disagreement (one Reader keeps a finished Series in
reading forever) is what the flag repairs; the cutover keeps polling
state unchanged for every Series.
2026-08-22 16:45:09 +07:00

5.5 KiB

ADR-0015: Finished is a fact about the Series, not a bookmark bucket

Date: 2026-08-22 Status: accepted

Decision

"Finished" moves from the per-Reader bookmarks.status bucket to a Series-owned flag: series.finished_at, unix ms, zero while the Series is still running. The Lane's gate reads the flag — a Series is polled only while finished_at = 0 — and bookmarks.status keeps exactly two values, reading and archived.

The cutover is one-way, done by migration 0016 in three load-bearing statements:

  1. ALTER TABLE series ADD COLUMN finished_at bigint NOT NULL DEFAULT 0.
  2. Seed it from the bookmarks: a Series is stamped finished when no bookmark on it is outside the finished bucket. This mirrors the pre-cutover due gate exactly — the old query skipped a Series only while COUNT(*) FILTER (WHERE status <> 'finished') = 0 — so no Series changes polling state at the cutover.
  3. Rewrite every finished bookmark to archived. The bucket is gone; the seed ran first because it is the only statement that can still read it.

The JSON API rejects a finished status with the same plain 400 as any unknown value, and the web UI no longer offers a Finished tab, a finish button, or a finished state badge.

Why a future reader will find this surprising

The bucket looked Reader-shaped but described a Series fact. A Series is finished once, and every Reader reading it is then on a finished Series — yet the bucket carried three copies of the answer, one per Reader, free to disagree. The disagreement is not theoretical: a second Reader who merely kept the Series (or never read it) kept it in reading, so the poll gate kept fetching a Series the first Reader had closed out, forever. Worse, the disagreement was never resolvable — nothing in the system could say "this Series is finished" without rewriting every bookmark, which silently edits another Reader's progress state.

The flag is also the only memory of the bucket after the flip. finished bookmarks become archived because a two-value status needs no third value, and an archived row must keep meaning "shelved, but the Series is being watched" — which is what the row says. The migration's seed is what keeps the legacy meaning: a Series every Reader finished is stamped, so the Lane stops polling it just as it would have pre-cutover; a Series any Reader still reads is left alone, exactly as the old gate left it. A Series whose every Reader only shelved (archived) continues to be polled, because an archived bookmark is supposed to be polled — the cutover changes the answer, it does not invent it. And because the flag is a series fact, the cutover also repairs the disagreement case: the moment one Reader has the Series open, it reads as finished to everyone.

The migration is the only writer of the flag today; the undo is writing 0, which returns the Series to the poll. An owner-facing "mark finished" write is deliberately not part of this change — the gate is what this ticket rewrites, and the write can land on top of it without touching anything here.

The userscript merge ranks archived > reading now. finished is not a value the wire can carry, so the merge cannot un-finish a row — it cannot even name the state it is protecting.

Considered options

Keep the bucket and add the flag alongside it, both live. Rejected: two sources of truth for one fact, with the Lane forced to resolve "any Reader finished?" on every due query and every Reader write still able to resurrect a finished Series. The whole point of the change is that the finished state survives Readers.

Stamp a Series finished when every bookmark is finished or archived. Rejected: it flips polling state at the cutover. Shelved-only Series were polled before; making them finished stops the checks the reader knowingly asked to keep.

Finish as "no bookmark is reading", leaving the buckets untouched. Rejected for the same reason plus one: archived is a Reader's own state and the flip is what makes the flag the only source of finished. Keeping the finished value in the table would force every status validation, merge and UI branch to keep handling a value no write can produce.

Consequences

  • bookmarks.status is validated to reading | archived, empty meaning "keep the stored value"; the API's 400 for finished is now the generic invalid-status rejection rather than a special case, and the web UI's own status control rejects it the same way.
  • The Lane due query and the eligible count read finished_at; a Forced Poll (issue #146) still overrides the flag — the owner asked, so the Lane looks — and the pending force clears itself when the pass stamps the check timestamp, never by touching finished_at.
  • The web UI has no Finished tab; finished Series render in Archived with their archived badge, dimmed like any shelved row.
  • Migration 0016 stamps finished_at with the migration's own clock (now() ms), which is also the undo: write 0 and the Series returns to the poll.

Cost of reversing

The finished buckets are destroyed by the flip; reversing means re-deriving per-Reader finished state from a Series fact that now encodes the majority-agreement snapshot plus whatever the owner reset since. The stamp differentiates "finished at the cutover and untouched" from "finished later", but not which Reader's choice each Series carried, and any Series the owner has since restored is gone from the derivation entirely. The ADR is a statement of intent to ship the one-way cutover and live with its consequences; the per-Reader history is not kept anywhere in the schema.