Spec #136: Finished belongs to the Series — owner-owned poll gate, Lifecycle bucket dropped (#163)

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>
This commit was merged in pull request #163.
This commit is contained in:
2026-08-22 17:31:52 +07:00
committed by sulthan
parent faa80c41ea
commit 8e4fa6448e
34 changed files with 1225 additions and 241 deletions
+109
View File
@@ -0,0 +1,109 @@
# 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.