Spec: Finished belongs to the Series - own the poll gate, drop the Reader Lifecycle bucket #136
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Spec derived from the wayfinder map #114, which is fully charted. This is spec 3 of 4; the map's
decisions were settled in #124 and are not re-decided here.
Blocked by: #134 (spec 1 of this series) (it creates the Series detail page the Finish control lives on, the
admin filter vocabulary this extends, and the Series list row that displays the state).
Problem Statement
One Reader's Lifecycle bucket silently decides whether the backend Polls a Series for everybody.
finishedis today a per-Reader value on a Bookmark, one of reading / archived / finished. But bothPoll Lane queries read it as though it were a fact about the Series: each requires at least one
Bookmark whose status is not
finished. So one Reader finishing a Series changes nothing, and everyReader finishing it stops the Poll for all of them. That is a Series-level effect assembled from
per-Reader votes, and nobody chose it.
Two consequences I can feel as owner:
it finished, which is not a decision anyone made and not one I can undo.
because everyone shelved it still shows a live Reader count, ages into "not checked in 12h" for
ever, and never leaves that list no matter what I do.
It is also the wrong shape by the project's own rules. A Series owns the facts that are true
regardless of who is reading — title, Cover, Latest Chapter — and a Reader cannot change them. Whether
a work has ended is exactly that kind of fact. The current model puts it on the Bookmark.
And as a Reader,
finishedis a third bucket I have to maintain that does nothing except, inaggregate and invisibly, break polling.
Solution
Finished becomes a fact about the Series, written only by the owner, and the reader-facing Lifecycle
bucket is deleted.
The owner marks a Series finished from its detail page. That single flag is what stops the Poll — no
votes, no aggregation. Un-finishing is one press and puts it straight back in the queue. A Forced Poll
overrides a finish for exactly one pass, and never clears it.
Readers lose the
finishedbucket and keep two: reading and archived. In its place they get aread-only label, derived from the Series, saying the work is finished — purely an explanation of why
nothing new will arrive. No tab, no filter, no reordering, no interaction with the new-chapter accent.
A Reader mid-way through a finished Series keeps it exactly where they left it.
The cutover carries today's behaviour across unchanged — every Series that was not being Polled
yesterday is still not being Polled tomorrow — while reinterpreting it as one owner decision instead
of a Reader vote.
User Stories
that has ended.
Readers' choices can stop it behind my back.
serialisation costs me nothing.
move that takes a Series out of the queue is deliberate and its reversal is not.
decision is made beside the facts it needs — Latest Chapter, when it last moved, whether the page
still reads — rather than as a neighbouring ghost in a fifty-row grid.
me the state even though it offers no one-press way to finish the wrong row.
so that an empty hygiene list stays achievable and "not checked in 12h" does not fill with rows I
deliberately retired.
Chapter to still appear in those filters, so that a genuine repair on a retired row is not hidden.
shelved Series without un-shelving it.
does not silently convert into permanently resumed polling.
on that Site, so that impatience about one Series cannot make us hammer a Site.
so that it reads as a Lane that declined and said why rather than as a stall.
retire a Series — a machine-written finish fails invisibly, because the Series stops being Polled
and nothing can contradict the mistake afterwards.
cutover does not silently resume polling on a set of Series nobody chose to resume.
information it needs is not destroyed before it is read.
why a Series everyone merely shelved is now marked finished and that undoing it is one click.
finishedbucket gone from my library, so that I stop maintaining a thirdstate whose only real effect was breaking polling for everyone.
that nothing disappears from my library in the cutover.
chapter will ever arrive.
my list, filter it, or interfere with the new-chapter accent.
work does not disturb my progress.
so that a stale cache from this morning cannot resume polling tonight.
so that the API's answer is consistent rather than a special case.
field from the API cannot inject anything into the page.
that finishing does not read as deleting — nothing is destroyed: no bytes, no row, no Bookmark.
than fixable, so that it does not sit among the counts that represent work to do.
Implementation Decisions
Recorded as an ADR — numbered in the tree, not on the map's paper numbering — because the
migration is one-way and destroys a user-facing bucket. Title: finished belongs to the Series.
The column
series.finished_at bigint NOT NULL DEFAULT 0. Epoch ms, zero means not finished — the same shape asthe Correction stamp, and it doubles as the undo (write zero) and as the "since when" the detail page
prints.
is per-Site).
finish fails invisibly: the Series stops being Polled, so nothing contradicts the mistake afterwards,
and no per-Series failure lens can see it either, because there are no attempts left to fail.
The two Lane queries
The
HAVING COUNT(*) FILTER (WHERE b.status <> 'finished') > 0clause is deleted from both. Theplain join to
bookmarksalready answers "does any Reader hold this"; whether to Poll becomesfinished_at = 0. Archived keeps polling, unchanged, for the reason already stated at that query.(s.finished_at = 0 OR s.force_poll_at > s.latest_checked_at)in itsWHERE, ands.finished_atjoins itsGROUP BYlist besideforce_poll_at.s.finished_at = 0and deliberately no force clause. That count isthe divisor in the Lane's pace, so admitting a forced Series would make one press speed up every
other fetch on the Site. The asymmetry between the two queries is intentional and
load-bearing — say so in a comment, because it looks like an oversight.
becomes "a Finished Series". The rationale transfers verbatim — the owner asking is direct evidence
someone cares about a Series the owner shelved. Everything a Forced Poll never overrides is
untouched.
zero; pending is derived and self-clears when the pass stamps the check timestamp before the
fetch. At that instant nothing has been read, so clearing the finish would act on zero evidence.
Un-finishing is an explicit action.
hits zero and the pass records
nothing-eligible. That is a Lane that declined and said why, not astall.
The Lifecycle bucket is removed
The glossary defines it as two states, reading or archived. Removal reaches, at minimum:
StatusFinishedconstant and the doc comment naming three states;finished("can only be set from the web UI") — the valuebecomes simply an invalid status like any other, with no special case and no special message;
button and its confirm row;
finished 2 / archived 1 / reading 0) drops to twovalues, and both userscripts' comments about the rejected value go with it.
Migration — the order is load-bearing
The seed reads the buckets, so it must run first. Put the column addition, the seed and the flip in
one migration file in that order, and say in a comment that the order is load-bearing.
This carries today's behaviour across the cutover unchanged, while reinterpreting it as one owner
decision. It knowingly over-approximates in the owner's favour — a Series everyone merely shelved
is declared finished, undone in one click — rather than seeding nothing and silently resuming polling
on a set nobody chose to resume.
Admin surface
neighbouring ghost is a harmless Check now. The list row's shape is unchanged; it still displays
the state.
Finish / Cancel. Un-finish fires instantly. This follows the reading UI's own rule
verbatim: every move out of a list goes through a confirm, a reversal fires straight away — the same
rule Archive and Restore already obey.
the admin patina accent, and never ember.
SeriesRowgainsFinishedAt int64, projected the way the force stamp is.POST /admin/series/{key}/finish(and its un-finish counterpart) joining the admin routelist behind the owner gate, answering with the swapped detail fragment.
Filters
finishedjoins the vocabulary, predicates.finished_at > 0, label Finished, orderedlast in the stats block as informational rather than fixable, with its own count in the aggregate.
The zero-renders-the-digit-unlinked rule and the one-filter-at-a-time select apply unchanged.
Recorded honestly: the recommendation while charting was to add no filter name at all, on the
ground that a deliberately finished Series is neither a problem nor actionable — which is the test that
admitted the other eight. The owner's call is that finished Series must be findable as a list, so the
name exists.
Four existing predicates gain
AND s.finished_at = 0:stale,unchecked,no-cover,no-chapter. A finished Series stops being checked, so without the guard it ages intostaleforever and the count never returns to zero, breaking the rule that an empty hygiene list is good news.
Three are deliberately not guarded:
unpollable,orphan,sighting-raised. A finished Serieswith no series URL, no Readers, or a Latest Chapter that came from a Sighting is still a row worth
repairing.
Contrast worth keeping in a comment: the four guarded predicates are computed from clocks that keep
ticking after the last Poll, so they lie about a finished row. A predicate computed from stored
outcomes needs no guard, because those simply stop arriving.
Readers see a label, and only a label
finished bool(s.finished_at > 0) on the flat wireobject. A bool, not the timestamp: the date the owner pressed a button is an operations fact
whose only consumer is the admin surface.
userscripts through the text-setting helper — never an HTML sink. The page DOM belongs to a
third-party site.
written this morning would carry
finished: falsetonight and un-finish the Series. The Upsert'sINSERT INTO seriesnames its columns explicitly andfinished_atis not among them — the samemechanism that already protects
cover. Do not add it, and do not "fix" the asymmetry.Glossary
Three edits to
CONTEXT.md, none of which have landed yet (verified 2026-08-21: the file still definesthe Lifecycle bucket as three states and still describes a Forced Poll as overriding "a Series only
finished Readers hold"):
Testing Decisions
What makes a good test here: it asserts observable behaviour across the cutover — what the Lane
polls, what the API accepts, what a page renders, what a client can and cannot write — and it fails on
a plausible bug. The migration itself gets a data test, because it is one-way and destroys a bucket.
The primary seam is unchanged: the router over a real store and a throwaway Postgres. The API
contract is asserted at the existing wire seam. No new seam and no new fake.
Modules and coverage:
migrations and asserts the resulting rows): seed a Series held only by finished Bookmarks and one
held by a mix; assert the first is finished and the second is not; assert every previously-finished
Bookmark reads
archived; assert the seed would produce the wrong answer if the two statements ranin the other order (that is the load-bearing fact, so it deserves a test that would catch a
reordering).
finished Series is not due; a finished Series is due when forced; servicing that forced pass
leaves
finished_atset; the eligible count excludes finished Series and does not admit a forcedone — assert the pace is unchanged by a force, which is the asymmetry's only observable consequence;
a Site whose whole worklist is finished records
nothing-eligiblerather than a stall; an archivedBookmark still keeps its Series polled.
the three unguarded ones include it; the aggregate's finished count; an Upsert carrying
finished: trueorfinished: falsefrom a client leaves the column untouched (this is the testthat stops a stale cache un-finishing a Series, and it is the highest-value test in this spec); the
derived
finishedbool reads back on the flat Bookmark.status: "finished"is now a plain400 with no special message; the flat Bookmark carries
finished; a PUT round-trip preserves theSeries' finished state.
step and un-finishing does not; the list row displays the state without offering the control; the
finishedfilter and its stats figure; the reader-facing label renders on the web card.the finished label renders as text.
Out of Scope
own "completed" marker may hint — that is the next spec, and its boundary is exactly this line.
Reader has for shelving.
question.
ADR exists because of this.
Further Notes
migrations and ADRs both stop at 0011, so the map's paper numbers (which called this migration 0016
and the ADR 0012) do not match the tree. Take the next free numbers after the earlier specs in this
series land.
plain
COUNTover Bookmarks knowingly disagreed with the Lane queries for as long as the bucketexisted; deleting the bucket makes the two the same set, exactly as predicted. Remove the note when
this lands.
finished_atis absent from its explicit column list. A future contributor "completing" that list isthe failure mode; the test above is what catches it.
may surface "this looks finished" beside the control, but it may not pre-fill it, add a second
button, or remove the confirm step. A hint that shortens the path is the Site deciding the Lifecycle.
reaches the wire format, both userscripts, the web templates and the poller in one cutover.