Spec: Finished belongs to the Series - own the poll gate, drop the Reader Lifecycle bucket #136

Closed
opened 2026-08-21 15:47:53 +07:00 by sulthan · 0 comments
Owner

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.

finished is today a per-Reader value on a Bookmark, one of reading / archived / finished. But both
Poll 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 every
Reader 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:

  • I cannot say "this work is over, stop polling it". I can only hope every Reader independently marks
    it finished, which is not a decision anyone made and not one I can undo.
  • The dashboard's hygiene counts are wrong in the other direction. A Series that nobody is polling
    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, finished is a third bucket I have to maintain that does nothing except, in
aggregate 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 finished bucket and keep two: reading and archived. In its place they get a
read-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

  1. As the owner, I want to mark a Series finished, so that the Lane stops spending Polls on a work
    that has ended.
  2. As the owner, I want that flag to be the only thing that stops the Poll, so that no combination of
    Readers' choices can stop it behind my back.
  3. As the owner, I want to un-finish a Series in one press, so that a mistake or a resumed
    serialisation costs me nothing.
  4. As the owner, I want finishing to be confirm-gated and un-finishing to fire instantly, so that the
    move that takes a Series out of the queue is deliberate and its reversal is not.
  5. As the owner, I want the Finish control on the Series detail page and nowhere else, so that the
    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.
  6. As the owner, I want the Series list to still show which rows are finished, so that a scan tells
    me the state even though it offers no one-press way to finish the wrong row.
  7. As the owner, I want finished Series findable as a list, so that I can review what I have shelved.
  8. As the owner, I want a finished Series to stop appearing in hygiene counts that measure staleness,
    so that an empty hygiene list stays achievable and "not checked in 12h" does not fill with rows I
    deliberately retired.
  9. As the owner, I want a finished Series with no page to fetch, no Readers, or a Reader-raised Latest
    Chapter to still appear in those filters, so that a genuine repair on a retired row is not hidden.
  10. As the owner, I want a Forced Poll to override a finish for one pass, so that I can check a
    shelved Series without un-shelving it.
  11. As the owner, I want servicing that forced request never to clear the finish, so that one check
    does not silently convert into permanently resumed polling.
  12. As the owner, I want one forced request never to speed up the Lane's pace for every other Series
    on that Site, so that impatience about one Series cannot make us hammer a Site.
  13. As the owner, I want a Site whose whole worklist is finished to log that it had nothing eligible,
    so that it reads as a Lane that declined and said why rather than as a stall.
  14. As the owner, I want the flag writable by nothing but me, so that no adapter, Reader or Poll can
    retire a Series — a machine-written finish fails invisibly, because the Series stops being Polled
    and nothing can contradict the mistake afterwards.
  15. As the owner, I want the migration to preserve today's polling behaviour exactly, so that the
    cutover does not silently resume polling on a set of Series nobody chose to resume.
  16. As the owner, I want the migration to seed from the buckets before it flips them, so that the
    information it needs is not destroyed before it is read.
  17. As the owner, I want to know that the seed over-approximates in my favour, so that I understand
    why a Series everyone merely shelved is now marked finished and that undoing it is one click.
  18. As a Reader, I want the finished bucket gone from my library, so that I stop maintaining a third
    state whose only real effect was breaking polling for everyone.
  19. As a Reader, I want my existing finished Bookmarks to become archived rather than to vanish, so
    that nothing disappears from my library in the cutover.
  20. As a Reader, I want a label on a Series the owner has finished, so that I understand why no new
    chapter will ever arrive.
  21. As a Reader, I want that label to be only a label, so that it does not move my Bookmark, reorder
    my list, filter it, or interfere with the new-chapter accent.
  22. As a Reader, I want to keep reading a finished Series from where I left off, so that retiring a
    work does not disturb my progress.
  23. As a Reader, I want my client to be unable to un-finish a Series by echoing back a cached label,
    so that a stale cache from this morning cannot resume polling tonight.
  24. As a Reader, I want an attempt to set the old bucket to be refused like any other invalid value,
    so that the API's answer is consistent rather than a special case.
  25. As a Reader, I want the label to render as text and never as markup, so that a Series title or a
    field from the API cannot inject anything into the page.
  26. As the owner, I want the Finish control to use the admin accent and not the destruction one, so
    that finishing does not read as deleting — nothing is destroyed: no bytes, no row, no Bookmark.
  27. As the owner, I want the finished figure ordered last in the stats block as informational rather
    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 as
the Correction stamp, and it doubles as the undo (write zero) and as the "since when" the detail page
prints.

  • No side table (one Series row already exists), and not on the per-Site Lane row (that grain
    is per-Site).
  • The owner writes it and nothing else ever does. No adapter, no Reader, no Poll. A machine-written
    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') > 0 clause is deleted from both. The
plain join to bookmarks already answers "does any Reader hold this"; whether to Poll becomes
finished_at = 0. Archived keeps polling, unchanged, for the reason already stated at that query.

  • The due query gains (s.finished_at = 0 OR s.force_poll_at > s.latest_checked_at) in its
    WHERE, and s.finished_at joins its GROUP BY list beside force_poll_at.
  • The eligible count gains s.finished_at = 0 and deliberately no force clause. That count is
    the 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.
  • This replaces one of the three waiting rules a Forced Poll overrides: "the finished-only bucket"
    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.
  • Servicing a forced request does not clear the finish. Nothing writes the force stamp back to
    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.
  • Consequence to accept, no new handling: on a Site where everything is finished the eligible count
    hits zero and the pass records nothing-eligible. That is a Lane that declined and said why, not a
    stall.

The Lifecycle bucket is removed

The glossary defines it as two states, reading or archived. Removal reaches, at minimum:

  • the store's StatusFinished constant and the doc comment naming three states;
  • the API handler's special-case 400 for finished ("can only be set from the web UI") — the value
    becomes simply an invalid status like any other, with no special case and no special message;
  • the store's status-validation fallback, which currently admits three values;
  • the web layer's tab filter and its status-to-UI mapping (two values there);
  • the templates: the Finished tab, the chrome's tab list, the list partial, the badge, the finish
    button and its confirm row;
  • the novel userscript's three-way merge rank (finished 2 / archived 1 / reading 0) drops to two
    values, and both userscripts' comments about the rejected value go with it.

Migration — the order is load-bearing

UPDATE series s SET finished_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint
 WHERE EXISTS (SELECT 1 FROM bookmarks b WHERE b.site = s.site AND b.series_id = s.series_id)
   AND NOT EXISTS (SELECT 1 FROM bookmarks b WHERE b.site = s.site AND b.series_id = s.series_id
                     AND b.status <> 'finished');

UPDATE bookmarks SET status = 'archived' WHERE status = 'finished';

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

  • Detail page only. The decision needs the facts that page shows, and the fifty-row grid's
    neighbouring ghost is a harmless Check now. The list row's shape is unchanged; it still displays
    the state.
  • Finish is confirm-gated with an in-place confirm row reading Mark this Series finished? with
    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.
  • Neither is the danger accent. Nothing is destroyed — no bytes, no row, no Bookmark — so both use
    the admin patina accent, and never ember.
  • SeriesRow gains FinishedAt int64, projected the way the force stamp is.
  • New route POST /admin/series/{key}/finish (and its un-finish counterpart) joining the admin route
    list behind the owner gate, answering with the swapped detail fragment.

Filters

finished joins the vocabulary, predicate s.finished_at > 0, label Finished, ordered
last 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 into stale for
ever 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 Series
with 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

  • The Bookmark column list gains a derived finished bool (s.finished_at > 0) on the flat wire
    object. A bool, not the timestamp: the date the owner pressed a button is an operations fact
    whose only consumer is the admin surface.
  • It reuses the badge markup and the moss token that removing the bucket frees, and renders in both
    userscripts through the text-setting helper — never an HTML sink. The page DOM belongs to a
    third-party site.
  • No tab, no filter, no reordering, no effect on the accent.
  • Read-only inbound, enforced by omission. Clients PUT the whole flat object back, so a cache
    written this morning would carry finished: false tonight and un-finish the Series. The Upsert's
    INSERT INTO series names its columns explicitly and finished_at is not among them — the same
    mechanism 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 defines
the Lifecycle bucket as three states and still describes a Forced Poll as overriding "a Series only
finished Readers hold"):

  1. Lifecycle bucket becomes two states, reading or archived.
  2. Forced Poll: "a Series only finished Readers hold" becomes "a Series the owner marked Finished".
  3. New term Finished Series.

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:

  • Migration (prior art: the existing migration test that seeds a pre-migration schema, runs the
    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 ran
    in the other order (that is the load-bearing fact, so it deserves a test that would catch a
    reordering).
  • Poller (prior art: the round-at-a-time pass tests with a fake fetcher and an injected clock): a
    finished Series is not due; a finished Series is due when forced; servicing that forced pass
    leaves finished_at set; the eligible count excludes finished Series and does not admit a forced
    one — 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-eligible rather than a stall; an archived
    Bookmark still keeps its Series polled.
  • Store: the finish and un-finish writes; the four guarded predicates exclude a finished Series and
    the three unguarded ones include it; the aggregate's finished count; an Upsert carrying
    finished: true or finished: false from a client leaves the column untouched (this is the test
    that stops a stale cache un-finishing a Series, and it is the highest-value test in this spec); the
    derived finished bool reads back on the flat Bookmark.
  • Wire / API (prior art: the existing status-validation tests): status: "finished" is now a plain
    400 with no special message; the flat Bookmark carries finished; a PUT round-trip preserves the
    Series' finished state.
  • Router / web: the Finish control renders only on the detail page; finishing requires the confirm
    step and un-finishing does not; the list row displays the state without offering the control; the
    finished filter and its stats figure; the reader-facing label renders on the web card.
  • Userscript (prior art: the existing logic tests): the merge rank has two values, not three, and
    the finished label renders as text.

Out of Scope

  • A machine-written finish of any kind. No adapter, Reader or Poll may write the column. A Site's
    own "completed" marker may hint — that is the next spec, and its boundary is exactly this line.
  • A reader-facing finished tab, filter, or reordering. The label is a label.
  • Exposing the finish timestamp to Readers. A bool on the wire, not the date.
  • A per-Reader "I have finished this" state in any form. Deleted, not relocated. Archived is what a
    Reader has for shelving.
  • Slowing or skipping a Lane for any reason other than the flag. Pacing is not a dashboard
    question.
  • Reversing the migration. It is one-way; the undo is the per-Series un-finish control, and the
    ADR exists because of this.

Further Notes

  • Migration and ADR numbers are claimed in landing order. Measured 2026-08-21: the tree's
    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.
  • The Reader count divergence recorded in the dashboard spec is resolved by this one. That spec's
    plain COUNT over Bookmarks knowingly disagreed with the Lane queries for as long as the bucket
    existed; deleting the bucket makes the two the same set, exactly as predicted. Remove the note when
    this lands.
  • This spec touches the security-reviewed Upsert only by not touching it: the protection is that
    finished_at is absent from its explicit column list. A future contributor "completing" that list is
    the failure mode; the test above is what catches it.
  • Orphan removal and this spec are independent: a finished Series still has Readers.
  • The Poll's next question is a hint, not a decision. The completion-marker work in the next spec
    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.
  • Run the full backend test suite plus the userscript logic tests before calling it done; this change
    reaches the wire format, both userscripts, the web templates and the poller in one cutover.
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.** `finished` is today a per-Reader value on a Bookmark, one of reading / archived / finished. But both Poll 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 *every* Reader 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: - I cannot say "this work is over, stop polling it". I can only hope every Reader independently marks it finished, which is not a decision anyone made and not one I can undo. - The dashboard's hygiene counts are wrong in the other direction. A Series that nobody is polling 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, `finished` is a third bucket I have to maintain that does nothing except, in aggregate 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 `finished` bucket and keep two: reading and archived. In its place they get a read-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 1. As the owner, I want to mark a Series finished, so that the Lane stops spending Polls on a work that has ended. 2. As the owner, I want that flag to be the only thing that stops the Poll, so that no combination of Readers' choices can stop it behind my back. 3. As the owner, I want to un-finish a Series in one press, so that a mistake or a resumed serialisation costs me nothing. 4. As the owner, I want finishing to be confirm-gated and un-finishing to fire instantly, so that the move that takes a Series out of the queue is deliberate and its reversal is not. 5. As the owner, I want the Finish control on the Series detail page and nowhere else, so that the 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. 6. As the owner, I want the Series list to still *show* which rows are finished, so that a scan tells me the state even though it offers no one-press way to finish the wrong row. 7. As the owner, I want finished Series findable as a list, so that I can review what I have shelved. 8. As the owner, I want a finished Series to stop appearing in hygiene counts that measure staleness, so that an empty hygiene list stays achievable and "not checked in 12h" does not fill with rows I deliberately retired. 9. As the owner, I want a finished Series with no page to fetch, no Readers, or a Reader-raised Latest Chapter to still appear in those filters, so that a genuine repair on a retired row is not hidden. 10. As the owner, I want a Forced Poll to override a finish for one pass, so that I can check a shelved Series without un-shelving it. 11. As the owner, I want servicing that forced request never to clear the finish, so that one check does not silently convert into permanently resumed polling. 12. As the owner, I want one forced request never to speed up the Lane's pace for every other Series on that Site, so that impatience about one Series cannot make us hammer a Site. 13. As the owner, I want a Site whose whole worklist is finished to log that it had nothing eligible, so that it reads as a Lane that declined and said why rather than as a stall. 14. As the owner, I want the flag writable by nothing but me, so that no adapter, Reader or Poll can retire a Series — a machine-written finish fails invisibly, because the Series stops being Polled and nothing can contradict the mistake afterwards. 15. As the owner, I want the migration to preserve today's polling behaviour exactly, so that the cutover does not silently resume polling on a set of Series nobody chose to resume. 16. As the owner, I want the migration to seed from the buckets before it flips them, so that the information it needs is not destroyed before it is read. 17. As the owner, I want to know that the seed over-approximates in my favour, so that I understand why a Series everyone merely shelved is now marked finished and that undoing it is one click. 18. As a Reader, I want the `finished` bucket gone from my library, so that I stop maintaining a third state whose only real effect was breaking polling for everyone. 19. As a Reader, I want my existing finished Bookmarks to become archived rather than to vanish, so that nothing disappears from my library in the cutover. 20. As a Reader, I want a label on a Series the owner has finished, so that I understand why no new chapter will ever arrive. 21. As a Reader, I want that label to be only a label, so that it does not move my Bookmark, reorder my list, filter it, or interfere with the new-chapter accent. 22. As a Reader, I want to keep reading a finished Series from where I left off, so that retiring a work does not disturb my progress. 23. As a Reader, I want my client to be unable to un-finish a Series by echoing back a cached label, so that a stale cache from this morning cannot resume polling tonight. 24. As a Reader, I want an attempt to set the old bucket to be refused like any other invalid value, so that the API's answer is consistent rather than a special case. 25. As a Reader, I want the label to render as text and never as markup, so that a Series title or a field from the API cannot inject anything into the page. 26. As the owner, I want the Finish control to use the admin accent and not the destruction one, so that finishing does not read as deleting — nothing is destroyed: no bytes, no row, no Bookmark. 27. As the owner, I want the finished figure ordered last in the stats block as informational rather 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 as the Correction stamp, and it doubles as the undo (write zero) and as the "since when" the detail page prints. - **No side table** (one Series row already exists), and **not on the per-Site Lane row** (that grain is per-Site). - **The owner writes it and nothing else ever does.** No adapter, no Reader, no Poll. A machine-written 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') > 0` clause is **deleted from both**. The plain join to `bookmarks` already answers "does any Reader hold this"; whether to Poll becomes `finished_at = 0`. Archived keeps polling, unchanged, for the reason already stated at that query. - **The due query** gains `(s.finished_at = 0 OR s.force_poll_at > s.latest_checked_at)` in its `WHERE`, and `s.finished_at` joins its `GROUP BY` list beside `force_poll_at`. - **The eligible count** gains `s.finished_at = 0` and **deliberately no force clause**. That count is the 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. - This replaces one of the three waiting rules a Forced Poll overrides: "the finished-only bucket" 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. - **Servicing a forced request does not clear the finish.** Nothing writes the force stamp back to 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. - **Consequence to accept, no new handling:** on a Site where everything is finished the eligible count hits zero and the pass records `nothing-eligible`. That is a Lane that declined and said why, not a stall. ### The Lifecycle bucket is removed The glossary defines it as **two** states, reading or archived. Removal reaches, at minimum: - the store's `StatusFinished` constant and the doc comment naming three states; - the API handler's special-case 400 for `finished` ("can only be set from the web UI") — the value becomes simply an invalid status like any other, with no special case and no special message; - the store's status-validation fallback, which currently admits three values; - the web layer's tab filter and its status-to-UI mapping (two values there); - the templates: the Finished tab, the chrome's tab list, the list partial, the badge, the finish button and its confirm row; - the novel userscript's three-way merge rank (`finished 2 / archived 1 / reading 0`) drops to two values, and both userscripts' comments about the rejected value go with it. ### Migration — the order is load-bearing ```sql UPDATE series s SET finished_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint WHERE EXISTS (SELECT 1 FROM bookmarks b WHERE b.site = s.site AND b.series_id = s.series_id) AND NOT EXISTS (SELECT 1 FROM bookmarks b WHERE b.site = s.site AND b.series_id = s.series_id AND b.status <> 'finished'); UPDATE bookmarks SET status = 'archived' WHERE status = 'finished'; ``` **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 - **Detail page only.** The decision needs the facts that page shows, and the fifty-row grid's neighbouring ghost is a harmless *Check now*. The list row's shape is unchanged; it still *displays* the state. - **Finish is confirm-gated** with an in-place confirm row reading *Mark this Series finished?* with **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. - **Neither is the danger accent.** Nothing is destroyed — no bytes, no row, no Bookmark — so both use the admin patina accent, and never ember. - `SeriesRow` gains `FinishedAt int64`, projected the way the force stamp is. - New route `POST /admin/series/{key}/finish` (and its un-finish counterpart) joining the admin route list behind the owner gate, answering with the swapped detail fragment. ### Filters **`finished` joins the vocabulary**, predicate `s.finished_at > 0`, label **Finished**, ordered **last** 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 into `stale` for ever 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 Series with 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 - The Bookmark column list gains a derived `finished bool` (`s.finished_at > 0`) on the flat wire object. **A bool, not the timestamp**: the date the owner pressed a button is an operations fact whose only consumer is the admin surface. - It reuses the badge markup and the moss token that removing the bucket frees, and renders in both userscripts through the text-setting helper — **never** an HTML sink. The page DOM belongs to a third-party site. - **No tab, no filter, no reordering, no effect on the accent.** - **Read-only inbound, enforced by omission.** Clients PUT the whole flat object back, so a cache written this morning would carry `finished: false` tonight and un-finish the Series. The Upsert's `INSERT INTO series` names its columns explicitly and `finished_at` is not among them — **the same mechanism 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 defines the Lifecycle bucket as three states and still describes a Forced Poll as overriding "a Series only finished Readers hold"): 1. **Lifecycle bucket** becomes two states, reading or archived. 2. **Forced Poll**: "a Series only finished Readers hold" becomes "a Series the owner marked Finished". 3. New term **Finished Series**. ## 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: - **Migration** (prior art: the existing migration test that seeds a pre-migration schema, runs the 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 ran in the other order (that is the load-bearing fact, so it deserves a test that would catch a reordering). - **Poller** (prior art: the round-at-a-time pass tests with a fake fetcher and an injected clock): a finished Series is not due; a finished Series **is** due when forced; servicing that forced pass leaves `finished_at` set; the eligible count excludes finished Series and **does not** admit a forced one — 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-eligible` rather than a stall; an archived Bookmark still keeps its Series polled. - **Store**: the finish and un-finish writes; the four guarded predicates exclude a finished Series and the three unguarded ones include it; the aggregate's finished count; an Upsert carrying `finished: true` or `finished: false` from a client leaves the column untouched (this is the test that stops a stale cache un-finishing a Series, and it is the highest-value test in this spec); the derived `finished` bool reads back on the flat Bookmark. - **Wire / API** (prior art: the existing status-validation tests): `status: "finished"` is now a plain 400 with no special message; the flat Bookmark carries `finished`; a PUT round-trip preserves the Series' finished state. - **Router / web**: the Finish control renders only on the detail page; finishing requires the confirm step and un-finishing does not; the list row displays the state without offering the control; the `finished` filter and its stats figure; the reader-facing label renders on the web card. - **Userscript** (prior art: the existing logic tests): the merge rank has two values, not three, and the finished label renders as text. ## Out of Scope - **A machine-written finish of any kind.** No adapter, Reader or Poll may write the column. A Site's own "completed" marker may *hint* — that is the next spec, and its boundary is exactly this line. - **A reader-facing finished tab, filter, or reordering.** The label is a label. - **Exposing the finish timestamp to Readers.** A bool on the wire, not the date. - **A per-Reader "I have finished this" state in any form.** Deleted, not relocated. Archived is what a Reader has for shelving. - **Slowing or skipping a Lane for any reason other than the flag.** Pacing is not a dashboard question. - **Reversing the migration.** It is one-way; the undo is the per-Series un-finish control, and the ADR exists because of this. ## Further Notes - **Migration and ADR numbers are claimed in landing order.** Measured 2026-08-21: the tree's 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. - **The Reader count divergence recorded in the dashboard spec is resolved by this one.** That spec's plain `COUNT` over Bookmarks knowingly disagreed with the Lane queries for as long as the bucket existed; deleting the bucket makes the two the same set, exactly as predicted. Remove the note when this lands. - **This spec touches the security-reviewed Upsert only by *not* touching it**: the protection is that `finished_at` is absent from its explicit column list. A future contributor "completing" that list is the failure mode; the test above is what catches it. - Orphan removal and this spec are independent: a finished Series still has Readers. - **The Poll's next question is a hint, not a decision.** The completion-marker work in the next spec 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. - Run the full backend test suite plus the userscript logic tests before calling it done; this change reaches the wire format, both userscripts, the web templates and the poller in one cutover.
sulthan added the ready-for-agent label 2026-08-21 15:47:53 +07:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sulthan/mangaBookmark#136