Closes #136. Spec #136 end to end: `finished` becomes a fact about the Series, written only by the owner, and the reader-facing Lifecycle bucket is gone. ## What landed - **#157** — `series.finished_at bigint NOT NULL DEFAULT 0` plus the migration whose statement order is load-bearing (seed from the buckets, then flip them); both Lane queries lose the `HAVING COUNT(*) FILTER (WHERE b.status <> 'finished')` clause and gate on `finished_at = 0` instead, with the due-query/eligible-count force asymmetry kept deliberate and commented; `StatusFinished`, its API special-case 400, the web tab and the templates' Finished bucket deleted. - **#158** — owner Finish control on the Series detail page: confirm-gated finish, instant un-finish, admin accent (never ember, nothing is destroyed), `Store.SetSeriesFinished`, the two routes behind the owner gate, and the state displayed on the list row without offering the control there. - **#160** — reader side: derived `finished` bool on the flat Bookmark (`s.finished_at > 0`), rendered as a text-only label in both userscripts and on the web card; read-only inbound by omission from `Upsert`'s explicit `series` column list, same mechanism that already protects `cover`. - **#161** — glossary and the stale Reader-count divergence note catch up. - **#159** — `finished` joins the admin filter vocabulary (predicate `finished_at > 0`, label `Finished`, own aggregate count, figure last in the stats block as informational); the four clock-driven hygiene predicates (stale, never-checked, no-cover, no-chapter) exclude finished Series while unpollable, orphan and sighting-raised deliberately do not. ## Verification `go vet ./...` and `go test ./...` green on the merged branch (Docker-backed, throwaway `postgres:17-alpine` per package). Each ticket also passed a two-axis review (spec + standards) on its own branch before merge. Reviewed-on: #163 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
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:
ALTER TABLE series ADD COLUMN finished_at bigint NOT NULL DEFAULT 0.- Seed it from the bookmarks: a Series is stamped finished when no bookmark
on it is outside the
finishedbucket. This mirrors the pre-cutover due gate exactly — the old query skipped a Series only whileCOUNT(*) FILTER (WHERE status <> 'finished') = 0— so no Series changes polling state at the cutover. - Rewrite every
finishedbookmark toarchived. 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.statusis validated toreading | archived, empty meaning "keep the stored value"; the API's 400 forfinishedis 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 touchingfinished_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_atwith 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.