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.
+14 -14
View File
@@ -2,7 +2,7 @@
Source of truth: the Claude Design project **BookmarkManager Web UI**
(`969ac210-fe02-4c01-ae1b-9a271dcc779a`, `index.html` + siblings
`archived.html`/`fav.html`/`finished.html`/`new.html`/`login.html`/`mobile.html`,
`archived.html`/`fav.html`/`new.html`/`login.html`/`mobile.html`,
`style.css`, `filter.js`). This file records the rules that got implemented so
a future agent can extend the UI without re-reading the design.
@@ -48,7 +48,7 @@ Defined once in `backend/internal/web/static/style.css` `:root`, mirrored in the
| --- | --- | --- | --- |
| `--ink` | `#100f0e` | `#f7f4ef` | page |
| `--ash` | `#161413` | `#efeae3` | recessed panel (chapter form) |
| `--dim` | `#0d0c0b` | `#f1ede7` | archived / finished row background |
| `--dim` | `#0d0c0b` | `#f1ede7` | archived row background |
| `--rule` | `#221f1d` | `#e0dad2` | hairline between sheets, button borders |
| `--rule-soft` | `#1a1817` | `#e8e3dc` | the measure's own side edges |
| `--field-line` | `#2c2926` | `#d4cdc4` | input borders, ghost-button underline |
@@ -70,7 +70,7 @@ Defined once in `backend/internal/web/static/style.css` `:root`, mirrored in the
| `--danger-soft` | `#e2aaa1` | `#7c2c22` | text on danger wash |
| `--brass` | `#b8912f` | `#8a681c` | favourite — a cooler second metal |
| `--slate` | `#7fa0c0` | `#3f6689` | archive accent |
| `--moss` | `#7fae86` | `#3d6c46` | finished accent |
| `--moss` | `#7fae86` | `#3d6c46` | finished Series label |
| `--clay` | `#b5906f` | `#7c5533` | set-chapter accent |
| `--trash` | `#977671` | `#8c6558` | remove, at rest — icons need 3:1, not 4.5:1 |
| `--patina` | `#5fb3a6` | `#1f6f66` | admin page only — a Poll Lane needing attention, a Reader whose reports are blocked |
@@ -164,9 +164,9 @@ rather than scaling the artwork down.
**Action key** (`.keyrow`): one permanent line under the tabs naming what
every icon in `.actions` does — Read / Fav / Chapter / Archive / Done /
Delete — so the icon strip on a card is never a guess. The key follows the tab,
not the row: Archive becomes Restore under Archived and Finished, and Finished
drops Done. On a phone each pair stacks icon-over-word
Delete — so the icon strip on a card is never a guess. The key follows the
tab, not the row: under Archived, Archive becomes Restore. On a phone each
pair stacks icon-over-word
(`flex-direction: column`) so the word gets the full cell width; ≥720px it lays
out icon-beside-word at the same wording. `.pair.brass` and `.pair.trash`
carry their icon's resting accent so the key itself teaches the colour
@@ -179,7 +179,7 @@ article.card[.is-new|.is-dim]#card-<key>[data-title]
.row
a.cover[tabindex="-1" aria-hidden] img | span.monogram, + span.foot-rule[.brass]
.body .title-line (h3.title + svg.fav-mark) , p.meta
.actions play, favourite, chapter | lifecycle: archive/restore, finish, remove
.actions play, favourite, chapter | lifecycle: archive/restore, remove
form.chapter-form[hidden] .hint + .field(input + Save) + .hint (latest known)
.confirm-row[.calm][hidden] × one per lifecycle action, span + (go/danger-solid, Cancel)
p.error-inline[hidden]
@@ -188,7 +188,7 @@ article.card[.is-new|.is-dim]#card-<key>[data-title]
Rules that are easy to break:
- `.is-new` only when `Status == reading && HasNewChapter`; `.is-dim` for
`archived` and `finished`. Both are set on the `<article>` — every heat and
`archived`. Both are set on the `<article>` — every heat and
dim rule is a descendant selector off those two classes, so a new sub-element
inherits the state for free.
- `.actions` is `flex: 1 0 100%` inside `.row`, which is what makes it a
@@ -196,19 +196,19 @@ Rules that are easy to break:
the row at ≥720px. Cells are 46px tall on phone (thumb target) and divided by
`border-right: 1px var(--rule)`, last child none.
- Three clusters by consequence, in this order: navigate (`.play`) | organize
(`.fav`, `.pencil`) | lifecycle (`.box`/`.restore`, `.finish`, `.remove`,
(`.fav`, `.pencil`) | lifecycle (`.box`/`.restore`, `.remove`,
each carrying the `.lifecycle` class). Lifecycle cells sit on a recessed
`--ash` ground so the thumb reads "this one moves the series" before it
reads which icon it landed on; ≥720px they separate by a 10px gap instead of
the phone's inset hairline.
- Every lifecycle button that moves a series out of the list is
**confirm-gated**: it opens its own `.confirm-row` (`archive`, `finish`,
`remove` — `toggleConfirmRow(key, kind)` in `filter.js`). Archive and finish
ask in `.calm` grey since they're reversible; remove alone gets the
**confirm-gated**: it opens its own `.confirm-row` (`archive`,
`remove` — `toggleConfirmRow(key, kind)` in `filter.js`). Archive asks in
`.calm` grey since it's reversible; remove alone gets the
`--danger-wash` treatment and names the series in its question. Restore
fires instantly — no confirm — because it's the reversal.
- Per-action hover/press accent: `.fav` → `--brass`, `.pencil` → `--clay`,
`.box` → `--slate`, `.finish` → `--moss`. `.play` stays paper/ember (ember
`.box` → `--slate`. `.play` stays paper/ember (ember
only when `.is-new`). `.remove` stays `--trash` at rest, `--danger` on
hover. Desktop cell borders follow the same accent on hover
(`border-color: currentColor`); the two coloured *resting* states
@@ -286,7 +286,7 @@ No transforms on hover, no scale, no easing curves beyond `ease-out`/`linear`.
never reuse `--ember` or `--danger` for anything but their one meaning.
3. If it is per-series, hang it off `.is-new` / `.is-dim` rather than adding a
third state class.
4. If it removes a series from the current view (archive/finish/remove-shaped),
4. If it removes a series from the current view (archive/remove-shaped),
it is confirm-gated via its own `.confirm-row` — no exceptions, restore is
the only instant action because it's the one that's reversible by nature.
5. Icon → `templates/icons.html`; nothing inlines SVG paths. Brand mark stays