Spec: owner data-correction actions - Latest Chapter, series_url, Cover, and orphan removal #135

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 2 of 4; the map's
decisions were split for implementability, not re-decided. Every decision below was settled in #120,
#121, #125 and #131.

Blocked by: #134 (spec 1 of this series) (the admin dashboard — it creates the Series detail page these
controls live on, the Forced Poll column the Cover replacement rides, and the orphan filter the
Remove control is reached from).

Problem Statement

The dashboard can now find broken Series. It cannot fix one.

Four concrete things I cannot do as owner:

A Cover that no longer matches the Site. The store only ever fills an empty cover address, so
once a Series has a Cover there is no path that replaces it. Worse, the address is derived from the
source URL, so a Site that re-arts a Series behind the same URL is invisible twice over: the file is
not written, the row is not updated, and the client holds the old bytes for a week under an immutable
cache header. Every Reader sees stale artwork and nothing in the system can tell.

A Latest Chapter I cannot make right. Where a Series' page still reads, a forced check fixes the
number — the Poll is the oracle. But where the page cannot be read at all, the stored number may have
come from a single Reader's report, nothing will ever confirm or contradict it, and no filter can find
it moving because it never moves.

A series URL that points at nothing. The column is write-once: it is set when the row is created
and no Poll and no Reader PUT ever changes it. So a Series whose address went bad stays bad for ever,
and the row is unrepairable by any client action.

Rows nobody holds. Removing a Bookmark leaves the Series behind and nothing deletes one, so the
row outlives every relationship to it: no Reader can reach it, no Poll visits it, and it still owns a
Cover file on a small disk. The dashboard now shows me these; it gives me no way to be rid of one.

And when I look at a number, I cannot tell where it came from — my own hand, a Reader's report, or a
machine read.

Solution

Four interventions on the Series detail page, and one derived line that explains a number's origin.

Cover replacement is not its own control. Asking for a Forced Poll is asking to accept the page
as it now stands, so a forced pass replaces the Cover while an ordinary pass keeps filling only a
blank one. Underneath, new Cover writes are addressed by the SHA-256 of the bytes rather than of the
source URL, which is what makes a re-art visible at all — and makes "the Site is serving the same
artwork" an honest no-op the page can report instead of a double silent failure.

A Latest Chapter correction with no authority. One numeric input. The value is overwritten by the
next successful Poll and by any Reader's report, and that is correct: the Poll is the oracle, and a
page read is stronger evidence than a typed number. No pin, no floor. It exists only for the Series
whose page cannot be read — where the page can be read, a forced check is the answer.

A series URL repair, owner-typed and gated. Validated by the same gate the poller already uses
before it fetches anything, because a client-supplied URL is exactly what that gate exists for. The
repair fetches nothing; the owner presses Check now afterwards.

Orphan removal, one row at a time. A plain delete, with the existing foreign key as the entire
guard — its refusal means "a Reader bookmarked it again". Cover bytes are reclaimed only when no
other Series points at them, by one guarded helper shared with the Cover replacement path.

Provenance is derived, never recorded. One line on the detail page naming the actor class behind
the current number: a Correction, a Sighting, or a machine read. No history table, and none is coming.

User Stories

  1. As the owner, I want a Series whose Cover no longer matches the Site to get the Site's current
    artwork, so that Readers stop seeing a picture the Site has replaced.
  2. As the owner, I want asking for a check and asking for fresh artwork to be one act, so that I do
    not have to reason about which of two buttons re-reads the page.
  3. As the owner, I want an ordinary Poll to keep filling only a blank Cover, so that routine polling
    does not move artwork under a Reader for no visible reason or spend a Cover fetch per Series per
    cycle.
  4. As the owner, I want a replacement that finds identical artwork to be reported as unchanged, so
    that I learn the Site has not re-arted rather than watching a button do nothing.
  5. As the owner, I want the Cover replacement to go through the same fetch routing everything else
    uses, so that the two Sites whose covers are gated keep working exactly as they already do.
  6. As the owner, I want a Cover replacement to degrade the same way a Poll does when the browser
    sidecar is asleep, so that there is no new failure mode and no new notification to learn.
  7. As the owner, I want no confirmation step on a Cover replacement, so that the remedy is not gated
    like a destruction — the Cover that disagrees with the Site is the confusing state, not the change.
  8. As a Reader, I want a replaced Cover to appear rather than being cached for a week, so that the
    fix is actually visible to me.
  9. As the owner, I want to set a Latest Chapter by hand on a Series whose page cannot be read, so
    that the number Readers see is not left at whatever one Reader's browser happened to report.
  10. As the owner, I want my hand-set number overwritten by the next successful Poll, so that the
    machine remains the authority and my typo cannot outlive the Site becoming readable again.
  11. As the owner, I want my hand-set number overwritten by any Reader's report too, so that there is
    exactly one rule — a page read beats a typed number — and not two.
  12. As the owner, I want to type a number and nothing else, so that a label cannot disagree with the
    number the new-chapter accent compares against.
  13. As the owner, I want a non-numeric or non-positive value refused, so that a bad value never
    reaches a row every Reader reads.
  14. As the owner, I want the page to tell me plainly that the next successful Poll overwrites my
    value, so that I do not mistake a stopgap for a pin.
  15. As the owner, I want to see for how long the live value has been mine, so that I can tell a
    correction that just landed from one that has stood unchallenged for a month.
  16. As the owner, I want that marker to disappear the moment a machine writes the number, so that it
    never reads as history.
  17. As the owner, I want an ordinary Progress PUT that resends my corrected number to leave the
    marker alone, so that the common case does not silently erase the fact that the value is mine.
  18. As the owner, I want a correction that contradicts a Reader's report to drop that attribution, so
    that the next Poll does not credit or blame that Reader for my number.
  19. As the owner, I want a correction never to count against a Reader's trust, so that a mark stays
    something a machine earned and my typo is not an unappealable penalty.
  20. As a Reader, I want a correction to reach me silently as just the Latest Chapter, so that there
    is no announcement to read and the new-chapter accent simply follows the number.
  21. As the owner, I want to repair a Series' page address, so that a row whose URL went bad stops
    being permanently unpollable.
  22. As the owner, I want a repaired address validated before it is stored, so that the server cannot
    be talked into probing arbitrary hosts from its own network position.
  23. As the owner, I want the repair to fetch nothing, so that the page does not imply it verified an
    address it only stored.
  24. As the owner, I want to press Check now after a repair, so that verification is an explicit act
    with a visible result.
  25. As the owner, I want the page to say plainly that a Site-wide host change is not this control's
    job, so that I do not sit typing the same fix into two hundred forms.
  26. As a Reader, I want to be unable to repoint a Series' address, so that one Reader cannot move a
    shared row onto a different work that the same gate would happily accept.
  27. As the owner, I want to remove a Series no Reader holds, so that rows nothing can reach stop
    accumulating and stop holding Cover bytes on a small disk.
  28. As the owner, I want the Remove control rendered only when no Reader holds the Series, so that I
    am never offered a control the database will refuse.
  29. As the owner, I want a removal that races a fresh bookmark to fail with a plain explanation, so
    that the refusal reads as "somebody bookmarked it again" rather than as an error.
  30. As the owner, I want Cover bytes reclaimed only when no other Series points at them, so that
    removing one Series cannot blank another's artwork.
  31. As the owner, I want an interrupted reclamation to be findable in one query, so that a partial
    failure leaves a repairable broken image rather than an unnamed file nothing can reach.
  32. As the owner, I want one Check now to repair a Series whose Cover file went missing, so that
    the recovery path is a control I already have.
  33. As the owner, I want the removal confirm copy to promise only what is certain, so that it does
    not claim byte deletion the guard may refuse or claim an effect on Readers who do not exist.
  34. As the owner, I want removal confirm-gated, so that the one action that pulls a row out of the
    list cannot be a single mis-click.
  35. As the owner, I want the list's heading count to update when I remove a row, so that a heading
    still claiming one orphan after the last one is gone does not need a page reload to clear.
  36. As the owner, I want removing from the detail page to send me back to the orphan list, so that I
    am not left refreshing a page whose subject no longer exists.
  37. As the owner, I want to know that removing an orphan is not a one-way door, so that I can act
    without weighing it as data loss.
  38. As the owner, I want one line telling me where the current Latest Chapter came from, so that I
    can judge whether to trust it before I act on it.
  39. As the owner, I want that line derived from state that already exists rather than from a history
    table, so that the dashboard never becomes a record of which Reader read what and when.

Implementation Decisions

Cover replacement rides the Forced Poll

  • No dedicated control and no second column. A Forced Poll takes the replace path; an unforced
    Poll keeps fill-if-blank unchanged. The pass already holds the Cover it read when it decides whether
    to fill a blank one, so a forced pass costs no extra page read.
  • Rejected: a second series.force_cover_at column — two columns, two verbs, two controls and another
    field on the admin projection, for a rare action. Rejected: a synchronous fetch inside the admin
    request — it contradicts the standing decision that intervention reaches the poller through the
    database, and it would block an owner request on the browser sidecar.
  • Accepted consequence, stated rather than hidden: the owner cannot refresh a Cover without also
    re-reading chapters. The two are one act — read this Series' page now and accept what it says.
  • The glossary already carries this: Forced Poll states that it takes whatever Cover the Site
    publishes today, and Acquisition no longer claims to be the only read that establishes a Cover.

The Cover address becomes a hash of the bytes

Three facts make a same-address replace impossible: the file is installed with a hard link that
ignores an existing target, the covers insert does nothing on conflict, and the public cover route is
served immutable for a week. So the address must change for a replacement to be visible — and
today it is the SHA-256 of the source URL.

  • New Cover writes are addressed by the SHA-256 of the bytes. Legacy URL-derived rows are left
    untouched, and there is no migration.
    The only contract on the value is the 64-hex-character
    pattern the store validates, so a mixed derivation is legal with no schema change and legacy rows
    stay readable.
  • Rejected: rehashing every stored file. That cannot be a migration at all — migrations are SQL-only,
    globbed by version — and would need a run-once Go step walking the whole cover tree at boot.
    Rejected: a URL hash salted with a per-Series refetch generation (a column whose value means nothing
    to a reader of the row).
  • Free consequence, and the reason this is worth doing: the new address equals the old one if and
    only if
    the Site serves the same bytes. "Unchanged" becomes observable instead of a silent no-op,
    and the page can say so.
  • Two deletions come with it. CoverAddress(sourceURL) has no production caller — only tests and
    store internals — so the "name a Cover before you have the bytes" property its doc comment defends
    is not load-bearing. And the prefetch path's existing-cover shortcut stops matching byte-addressed
    rows: delete the shortcut and let that legacy heal path fetch, rather than adding a source_url
    column to the covers table to keep an optimisation on a rare repair.
  • This needs an ADR. The rule is hard to reverse once data exists under both derivations, and it
    contradicts the URL-hash statement in the cover-address migration and in the store's own doc
    comment, which the change must rewrite.

Store surface for the replacement

ReplaceSeriesCover(site, seriesID, sourceURL string, body []byte, contentType string) error beside
the existing SetSeriesCover. Both go through the same cover installer and differ only in the update
predicate: the existing method keeps its empty-address guard.

  • Rejected: a force bool parameter. Three existing callers would pass false for ever, and the
    first caller that passes true turns fill-if-empty into overwrite at the call site rather than in
    the store. The existing test that asserts a Cover is not overwritten guards behaviour that should
    stay unparameterised.
  • The forced update writes cover as well as cover_address, because a re-art may sit behind a new
    source URL.
  • Fetch path: the existing one. Reuse the existing cover fetch with its existing fetcher routing,
    so the gated cover host and the browser-only Site keep going through the sidecar and everything else
    goes over plain TLS — one routing rule for Acquisition, Poll and this action. No pre-flight
    refusal when the sidecar is down
    : the Lane skips its pass, the request marker ages, and an ageing
    marker is already the evidence that a Lane is stuck. The Lanes page already states the sidecar fact,
    so no new notification is added.
  • The Series row says only how old the request is. It never repeats the Lane-level reason — the Lanes
    page holds that, per the one-figure-in-one-place rule. Rejected: a "no browser" mark on every Series
    row of a browser Site, which would put Lane state into the Series list.
  • No confirmation, no danger accent, never ember. A newer Cover is what the Site now publishes; a
    Cover that disagrees with the Site is the state that confuses the Reader, so the change is the
    remedy and not the risk. The confirm law covers actions that pull a Series out of a list, and this
    pulls none.

Latest Chapter correction

The motivating case was not a defect. The lightnovelworld ticket that prompted this settled the
opposite: the chapter-list maximum is Latest Chapter, and capping it at the Site's own
newest-chapter banner is pre-rejected. A Reader below that maximum genuinely has unread chapters and
the accent is earned. So "fixes the class of that issue" is struck.

What survives is one class no machine can reach: a Reader-raised value on a Series whose page the Poll
cannot read. Where the page is readable, a Forced Poll is the remedy — the pass judges the
outstanding Sighting and rewrites from the Site — so a correction on a readable Series is not a
correction, it is a workaround for an adapter bug.

There are two overwriters, not one. The poller rewrites on any inequality with what the Site
publishes; and the series Upsert's conflict clause is unconditional last-write-wins from any
Reader's PUT, which both userscripts fire on every series-page visit, sending the site-read value up
or down and sending it even unchanged. So "sticky" was never one guard: it would be a pin column
plus a guard on the poller path and on the client write path every Reader hits.

Decisions:

  • Not authoritative. No pin, no floor. The correction reuses the existing value columns and dies
    the moment any machine write lands. A floor is rejected outright: it would have made the downward
    direction — the one that was actually right in the motivating case — unrepairable. On the class this
    control is for, nothing overwrites it anyway; that is what "the Poll cannot read the page" means.
  • One numeric input; the label is derived. The handler requires a finite float greater than zero,
    else 400. The stored label is "Chapter " + the formatted number. Two inputs are rejected: nothing
    depends on matching a Site's typography, and a free-text label invents a failure mode where the
    number is right and the panel renders the typo. Leaving the old label is worse still — the panel
    would keep saying the old chapter after the number moved.
  • New column series.latest_corrected_at bigint NOT NULL DEFAULT 0 meaning "the current value is a
    human's"
    , written by the correction and zeroed by every machine write of the value:
    • a new CorrectLatestChapter(site, seriesID string, num float64, ts int64) error sets the label,
      the number and the stamp;
    • the poller's chapter setter gains , latest_corrected_at = 0 in the same update — already
      conditional in effect, since the pass returns early on equality and so only writes when the number
      moved;
    • the series Upsert gains one clause in its existing conflict update, conditional on the value
      actually changing
      :
      latest_corrected_at = CASE WHEN excluded.latest_chapter_num IS DISTINCT FROM series.latest_chapter_num THEN 0 ELSE latest_corrected_at END.
      Unconditional zeroing would be wrong for the common case: after a correction a Reader's cached row
      holds the corrected number, so an ordinary Progress PUT resends it and would clear the stamp while
      the value is still the owner's. One clause in an existing statement — no extra round trip, no
      second update — but it touches the security-reviewed Upsert, so say so in that PR.
  • This is deliberately not an audit trail: no history, no corrected_by (there is one owner), and
    a stamp cannot outlive the value it describes.
  • A contradicting correction clears the Sighting attribution without judging it. After a successful
    correction, if the Series carries a raising Reader, call ClearSightingAttribution — never
    RecordSightingOutcome(..., false). Marking the Reader is tempting (this is the one class where a
    mark would otherwise never land) but recovery is twenty confirming Polls, and this control exists
    precisely because no Poll can read the page — so a mark earned here is permanent in practice and an
    owner's typo would be unappealable. Marks stay machine-earned. Leaving the attribution in place is
    wrong outright: the next Poll would credit or blame that Reader for the owner's number.
  • Corrections are silent to Readers. No notification. The corrected label simply is the Latest
    Chapter, and the accent follows the number against each Reader's own progress, so a downward
    correction can only extinguish an accent and an upward one lights it — which is what "the Site
    published" means.

Series URL repair

  • New store method SetSeriesURL(site, seriesID, url string) error; the handler validates with the
    existing gate before storing. That gate is currently unexported in the poller, so it is
    renamed to latest.FetchableSeriesURL with its in-package callers updated — one implementation,
    never a second gate. The web package already imports latest.
  • No synchronous fetch in the request. The owner presses Check now afterwards.
  • This lifts the write-once rule the series migration states — client-supplied values are ignored once
    the row exists
    — for the owner only.
  • Rejected: letting a Reader's PUT heal a stale URL. The client already sends a fresh one every
    time and the store drops it, deliberately. series is a shared row: one Reader could repoint a
    Series every other Reader reads at a different work on the same host, and the gate would pass it,
    because the gate stops SSRF, not mis-pointing.
  • Honest limit, stated on the page rather than papered over: a Site-wide host change invalidates
    every row of that Site at once, and a per-Series form is the wrong tool for it. That is a SQL
    migration and is out of scope.
  • The repair records nothing. It changes what the Poll fetches, not what a Reader reads.

Orphan removal

  • An owner action, one at a time, orphans only. Remove sits on the Series list row and on the
    detail page, and both render it only at a zero Reader count.
    • Rejected: a permanently-disabled or always-present Remove the database refuses — a dead control
      is one the owner learns to ignore, and the foreign-key refusal is for the race, not for everyday
      feedback.
    • Rejected: a bulk sweep over the orphan filter — an unbounded delete over rows the owner has not
      read individually is a different risk class, and the orphan set is short by construction (a Reader
      has to drop the last Bookmark).
  • The database is the guard. A plain DELETE FROM series WHERE site = $1 AND series_id = $2. The
    existing bookmarks-to-series foreign key has deliberately no cascade, so it refuses the delete while
    any Bookmark exists — which is the definition of orphan. No NOT EXISTS pre-check: it would
    add a read-then-write window and a second copy of the definition that can drift. A foreign-key
    violation is translated at the handler into "a Reader has bookmarked this Series again" and the row
    re-rendered with its new count.
  • Not a one-way door, and the spec says so: the next PUT re-inserts the series row and Acquisition
    or a Poll refetches the Cover. Removing an orphan discards a row nothing reads, not history.
  • Check now is not offered on an orphan row. A Forced Poll never overrides the Bookmarks join, so
    the press would write the request, no Lane would ever consume it, and the row would age a pending
    marker for ever — firing the stuck-Lane signal on a Series that is not stuck. A row at zero Readers
    carries Remove only. Rejected: making a Forced Poll override the join. A Series with no Reader has
    no consumer for the result.

Cover byte reclamation — one guarded helper, two callers

DELETE FROM covers WHERE address = $1 AND NOT EXISTS (SELECT 1 FROM series WHERE cover_address = $1),
plus the unlink of the sharded path. Called at both causes: orphan removal, and the forced Cover
replace whose byte-derived address strands the old one the moment the art changes. series.cover_address
is the only reference that keeps bytes alive; series.cover holds the third-party source URL and
references nothing.

  • The guard is not optional. Sharing is rare but real. Measured 2026-08-18 against
    demonicscans.org: twelve Series from the home page gave twelve distinct cover addresses and twelve
    distinct byte digests, and a page for a Series that does not exist publishes no cover image at all,
    so there is no shared placeholder. Rare, not impossible.
  • Order: unlink the file first, delete the covers row last. This inverts the obvious order, and the
    reason is that the covers row is the handle. Keep it until last and an interrupted reclamation is
    discoverable in SQL — covers rows unreferenced by any series cover address — and re-running finishes
    the job. Delete the row first and the leftover file is named by nothing: no query lists it and finding
    it means walking the sharded tree and diffing against the database. That is dark garbage needing
    manual deletion; the alternative is a repairable broken image.
  • An unlink failure other than "already gone" leaves the row in place and logs. Keeping the handle
    is what makes a retry possible.
  • For orphan removal the whole sequence is: delete the series row, then guard-check, then unlink, then
    delete the covers row — the guard cannot pass while the series row still points at the address.
  • A row whose file is gone is repaired by one Check now, whether the Site serves the same bytes
    (same address, file re-linked) or new ones (new address, row repointed). The forced replace path has
    no empty-address predicate, which is what makes this true; the earlier note that a non-blank cover
    address is unrepairable described the routine paths only and is superseded.
  • One hazard ordering cannot fix: a concurrent Forced Poll re-pointing a live Series at that address
    between guard and unlink. Narrow, and its outcome is the repairable case.
  • Rejected: a sweep over the whole covers table, owner-triggered or opportunistic. It runs a
    whole-table predicate — the one whose being wrong is unrecoverable — to fix a problem two known
    callsites prevent, and it needs a schedule this backend has no ticker for. Rejected: never
    reclaiming; a Cover is 50–500 KB on a swapless 1974 MiB VPS while a series row is 200 bytes.
  • Free consequence: a sweep, if ever wanted later, is one SQL query over the covers table rather
    than a tree walk. This spec does not build it.

Latest Chapter provenance — derived, never recorded

Nothing records how the number came to hold its value, and nothing will. No history table, no
attribution column, no migration. Three actor classes, all derived from state this series already
decides:

  • Correction — the correction stamp is non-zero. Means "the number on this row is a human's".
  • Sighting — the Series carries a raising Reader. A Reader's report raised the number and no Poll
    has judged it; surfaced anonymously as the admin row's Sighting flag.
  • Machine read — neither of the above, with a non-zero check stamp.

Acquisition does not survive as a distinct actor. Acquisition stamps the check timestamp exactly as
a Poll does, so an acquired value and a polled value are indistinguishable the moment the Acquisition
finishes. Accepted deliberately: the only case where the difference is actionable — acquired once and
never read again — is already the unchecked filter, and telling the two apart would need a column,
which is the thing this declines to add.

Consequence: the correction stamp is promoted from convenience to load-bearing. It is the only
evidence a Correction ever happened, so it must not be dropped, and its zeroing rules are what keep the
derivation honest.

Presentation: one line on the Series detail page naming the actor class beside the number.
Owner-only by route. No list-row field (a row has two lines), no new filter name, no landing figure —
an actor class is context for a Series you are already looking at, never a population to sweep.

The Cover half is closed by construction, and a superseded Cover is deliberately unrecoverable: new
bytes are addressed by their own hash, so a replacement is visible in the address itself, and the
unreferenced covers row is reclaimed. The Site is the only true source of what the art should be.

A history table was designed before it was dropped, and the design is recorded so it is not
re-proposed: one row per actual change (a no-change PUT and a confirming Poll writing nothing, roughly
52 rows per Series per year), actor class only, newest-twenty-per-Series pruned on insert in the lazy
style the sessions table already uses. Three costs killed it:

  1. A per-Series timestamped table is one column away from the log this project forbids. Add a
    Reader id — the obvious next request, since a Sighting comes from someone — and it becomes a record
    of which Series each Reader reads and when.
  2. It would duplicate a fact with a second writer. "The current value is a human's" would live in
    both the correction stamp and the newest row's actor class, and the two would eventually disagree.
  3. No owner question needs the past. The Poll is the oracle, so the next successful read
    re-establishes the number without reference to history; distrust of a Reader is already the
    Sighting-disagreement counter, which is durable, judged by Polls, and self-healing over twenty
    agreements. A trail's unique payload was "who to distrust", and that mechanism already exists and is
    better.

Routes and presentation

Route Body Notes
POST /admin/series/{key}/latest num finite float > 0, else 400
POST /admin/series/{key}/series-url url must pass latest.FetchableSeriesURL, else 400
POST /admin/series/{key}/remove — orphans only; the FK is the guard

All join the admin route list behind the owner gate, and form bodies are capped the way the API path
caps them.

  • No predicate gate on the correction controls. Both sit on the detail page unconditionally, with
    copy saying the next successful Poll overwrites the value. Gating on "looks unverifiable" would make
    this wait on per-Series failure state for cosmetics, and every gate variant forbids the repair exactly
    when it gets easy — a briefly-healed Site does not make a wrong stored number right.
  • Correction and repair are unconfirmed (the confirm law gates what pulls a Series out of the list,
    and neither does), never ember, no danger accent. A corrected <age> ago marker renders while the
    stamp is non-zero; it is transient by design and must not read like history.
  • Removal is confirm-gated with the in-place confirm row on the danger wash. Confirm copy states
    only what is certain
    : "Removes this series and its stored cover. No Reader has it bookmarked; one
    re-bookmarking it recreates the row."
    Byte deletion is not promised — the guard decides. The earlier
    draft copy claiming it removes the Series "for every Reader" and deletes its Cover bytes is wrong
    twice over: an orphan has no Readers, and the bytes may survive the guard.
  • Sharing is not disclosed and a failed unlink gets no surface. No "shared with N other series"
    line: it costs a query per confirm-row render and buys a fact the owner cannot act on. No
    "unreclaimed covers: N" figure for a state that has never occurred and that the page cannot fix. A
    failed unlink is logged, and the ordering above makes it findable.
  • Responses. The list row answers with the removed row's fragment plus a second out-of-band snippet
    re-rendering the <N> series · <label> heading — the row and the count are one fact, and a heading
    still reading 1 after the last orphan is gone is a lie the owner must reload to clear. The filter
    select's option counts stay stale until the next navigation: eight snippets per delete to keep one
    number honest is not worth it. The detail page redirects to the orphan list — the subject no longer
    exists, a refresh would 404, and the list is where the owner was headed.
  • Detail page layout: the two correction forms sit in the two-column grid the dashboard spec reserved,
    with Check now below; the provenance line sits beside the number.

Testing Decisions

What makes a good test here: it asserts an outcome the owner or a Reader can observe — a stored
value after a request, a rendered marker, a file's presence, a refusal's status code — and it fails on a
plausible bug. Nothing asserts a helper's internals or a log line.

The primary seam is unchanged and existing: the router over a real store and a throwaway Postgres,
driven with an owner session cookie. Every control is reached as a POST and asserted through the
response and the resulting database state. No new seam, no new fake.

Modules and coverage:

  • Router / web (prior art: the existing admin roster and owner-gate tests): each new route is
    owner-gated by joining the route list; a non-numeric, zero or negative chapter is 400 and does not
    reach the store; a URL failing the gate is 400 and does not reach the store; Remove is rendered
    only at a zero Reader count; a removal that hits the foreign key renders "a Reader has bookmarked
    this Series again" with the fresh count rather than a 500; the removal response carries the
    re-rendered heading count; the detail page redirects after removal; the corrected <age> ago marker
    appears while the stamp is set and is gone after a machine write; the provenance line names each of
    the three actor classes for the three states that produce them.
  • Store (prior art: the existing store tests over a throwaway Postgres with real migrations):
    CorrectLatestChapter stamps and the poller's chapter setter zeroes; an Upsert with the same
    number keeps the stamp and one with a different number zeroes it (this pair is the whole point of
    the conditional clause and is the most likely thing to get wrong); a correction clears the raising
    Reader and leaves both Sighting counters untouched; ReplaceSeriesCover overwrites a non-empty
    address while the existing setter still refuses to (the existing no-overwrite test must keep
    passing unchanged — it is the guard that this change did not leak into the routine path); the same
    bytes yield the same address and different bytes a different one; the reclamation helper deletes the
    covers row and the file when nothing points at the address, and deletes neither when a second
    Series does; the delete of a series row is refused while a Bookmark exists and succeeds when none
    does; SetSeriesURL writes where the Upsert would not.
  • Poller (prior art: the round-at-a-time pass tests with a fake fetcher and an injected clock): a
    forced pass replaces an existing Cover and an unforced pass does not; a forced pass over identical
    bytes is a no-op the caller can distinguish; a forced pass on a Series whose Cover file was unlinked
    re-links or repoints the row; the Cover fetch routes through the same fetcher selection as the page
    read.
  • Filesystem behaviour is asserted, not assumed: the reclamation test checks the sharded path is
    actually gone, and the interrupted case (row present, file absent) is asserted to be findable by the
    unreferenced-covers query and repaired by a forced pass.

Out of Scope

  • A Latest Chapter pin or floor. Rejected on the merits above; the Poll is the oracle.
  • An authoritative hand-set value of any kind, including one that survives a Reader's PUT.
  • Reader-side repair of a series URL. series is shared; the gate stops SSRF, not mis-pointing.
  • Bulk rewrite of series URLs across a whole Site. A host change invalidates every row of a Site at
    once. That is a SQL migration, and the dashboard's library-wide job is finding broken rows.
  • A bulk orphan sweep, and any owner-triggered or scheduled sweep over the covers table.
  • A dedicated Cover-refetch control, a force_cover_at column, or a synchronous Cover fetch in the
    request.
  • Rehashing existing Cover addresses. Legacy URL-derived rows stay as they are, for ever.
  • Any Latest Chapter history, provenance column, or audit trail — including a "went backwards"
    filter, whose first ground (nothing stores the previous number) stays true, and a flap detector,
    which was reachable only under the history option and dies with it.
  • Notifying Readers of a correction or a replaced Cover.
  • Recovering a superseded Cover. Deliberate: the Site is the only true source of what the art
    should be.
  • A "shared with N other series" disclosure or an unreclaimed-covers figure.

Further Notes

  • Migration numbers are claimed in landing order. Measured 2026-08-21: the tree's migrations and
    ADRs both stop at 0011 and everything the map specified is unbuilt. This spec's only schema work is
    series.latest_corrected_at bigint NOT NULL DEFAULT 0; take the next free number after the
    dashboard spec's migrations.
  • One ADR is required with the implementation: Cover addresses derived from the bytes rather than
    the source URL. It is hard to reverse once data exists under both derivations, and it contradicts
    statements the change must rewrite in the cover-address migration and the store's doc comment. The
    reclamation rule needs no ADR — it is one helper and reversible.
  • Glossary: the two edits this work implies already landed while charting — Forced Poll states it
    takes whatever Cover the Site publishes today, Acquisition no longer claims to be the only read
    that establishes a Cover, and Correction is defined. Nothing new is needed.
  • This spec constrains the next two. A human correction must not clear any per-Series failure
    state that a later spec adds: an owner typing a number is not evidence the page became readable, and
    only a successful Poll may reset it. Any new per-Series table must key on (site, series_id) with
    ON DELETE CASCADE to series, so orphan removal stays a single statement and the FK-as-orphan-test
    property survives — the cascade must reach poll state only, never bookmarks, whose refusal is
    the guard.
  • Security-critical surfaces touched: the fetch gate (renamed and now called from a second package —
    one implementation, no second gate), the security-reviewed series Upsert (one new conditional clause),
    and the owner gate. Say which invariant you preserved in the PR and run the full backend test suite
    before calling it done.
Spec derived from the wayfinder map #114, which is fully charted. This is spec 2 of 4; the map's decisions were split for implementability, not re-decided. Every decision below was settled in #120, #121, #125 and #131. Blocked by: #134 (spec 1 of this series) (the admin dashboard — it creates the Series detail page these controls live on, the Forced Poll column the Cover replacement rides, and the orphan filter the Remove control is reached from). ## Problem Statement The dashboard can now find broken Series. It cannot fix one. Four concrete things I cannot do as owner: **A Cover that no longer matches the Site.** The store only ever *fills* an empty cover address, so once a Series has a Cover there is no path that replaces it. Worse, the address is derived from the source URL, so a Site that re-arts a Series behind the same URL is invisible twice over: the file is not written, the row is not updated, and the client holds the old bytes for a week under an immutable cache header. Every Reader sees stale artwork and nothing in the system can tell. **A Latest Chapter I cannot make right.** Where a Series' page still reads, a forced check fixes the number — the Poll is the oracle. But where the page cannot be read at all, the stored number may have come from a single Reader's report, nothing will ever confirm or contradict it, and no filter can find it moving because it never moves. **A series URL that points at nothing.** The column is write-once: it is set when the row is created and no Poll and no Reader PUT ever changes it. So a Series whose address went bad stays bad for ever, and the row is unrepairable by any client action. **Rows nobody holds.** Removing a Bookmark leaves the Series behind and nothing deletes one, so the row outlives every relationship to it: no Reader can reach it, no Poll visits it, and it still owns a Cover file on a small disk. The dashboard now shows me these; it gives me no way to be rid of one. And when I look at a number, I cannot tell where it came from — my own hand, a Reader's report, or a machine read. ## Solution Four interventions on the Series detail page, and one derived line that explains a number's origin. **Cover replacement is not its own control.** Asking for a Forced Poll *is* asking to accept the page as it now stands, so a forced pass replaces the Cover while an ordinary pass keeps filling only a blank one. Underneath, new Cover writes are addressed by the SHA-256 of the *bytes* rather than of the source URL, which is what makes a re-art visible at all — and makes "the Site is serving the same artwork" an honest no-op the page can report instead of a double silent failure. **A Latest Chapter correction with no authority.** One numeric input. The value is overwritten by the next successful Poll and by any Reader's report, and that is correct: the Poll is the oracle, and a page read is stronger evidence than a typed number. No pin, no floor. It exists only for the Series whose page cannot be read — where the page *can* be read, a forced check is the answer. **A series URL repair, owner-typed and gated.** Validated by the same gate the poller already uses before it fetches anything, because a client-supplied URL is exactly what that gate exists for. The repair fetches nothing; the owner presses *Check now* afterwards. **Orphan removal, one row at a time.** A plain delete, with the existing foreign key as the entire guard — its refusal *means* "a Reader bookmarked it again". Cover bytes are reclaimed only when no other Series points at them, by one guarded helper shared with the Cover replacement path. **Provenance is derived, never recorded.** One line on the detail page naming the actor class behind the current number: a Correction, a Sighting, or a machine read. No history table, and none is coming. ## User Stories 1. As the owner, I want a Series whose Cover no longer matches the Site to get the Site's current artwork, so that Readers stop seeing a picture the Site has replaced. 2. As the owner, I want asking for a check and asking for fresh artwork to be one act, so that I do not have to reason about which of two buttons re-reads the page. 3. As the owner, I want an ordinary Poll to keep filling only a blank Cover, so that routine polling does not move artwork under a Reader for no visible reason or spend a Cover fetch per Series per cycle. 4. As the owner, I want a replacement that finds identical artwork to be reported as unchanged, so that I learn the Site has not re-arted rather than watching a button do nothing. 5. As the owner, I want the Cover replacement to go through the same fetch routing everything else uses, so that the two Sites whose covers are gated keep working exactly as they already do. 6. As the owner, I want a Cover replacement to degrade the same way a Poll does when the browser sidecar is asleep, so that there is no new failure mode and no new notification to learn. 7. As the owner, I want no confirmation step on a Cover replacement, so that the remedy is not gated like a destruction — the Cover that disagrees with the Site is the confusing state, not the change. 8. As a Reader, I want a replaced Cover to appear rather than being cached for a week, so that the fix is actually visible to me. 9. As the owner, I want to set a Latest Chapter by hand on a Series whose page cannot be read, so that the number Readers see is not left at whatever one Reader's browser happened to report. 10. As the owner, I want my hand-set number overwritten by the next successful Poll, so that the machine remains the authority and my typo cannot outlive the Site becoming readable again. 11. As the owner, I want my hand-set number overwritten by any Reader's report too, so that there is exactly one rule — a page read beats a typed number — and not two. 12. As the owner, I want to type a number and nothing else, so that a label cannot disagree with the number the new-chapter accent compares against. 13. As the owner, I want a non-numeric or non-positive value refused, so that a bad value never reaches a row every Reader reads. 14. As the owner, I want the page to tell me plainly that the next successful Poll overwrites my value, so that I do not mistake a stopgap for a pin. 15. As the owner, I want to see for how long the live value has been mine, so that I can tell a correction that just landed from one that has stood unchallenged for a month. 16. As the owner, I want that marker to disappear the moment a machine writes the number, so that it never reads as history. 17. As the owner, I want an ordinary Progress PUT that resends my corrected number to leave the marker alone, so that the common case does not silently erase the fact that the value is mine. 18. As the owner, I want a correction that contradicts a Reader's report to drop that attribution, so that the next Poll does not credit or blame that Reader for my number. 19. As the owner, I want a correction never to count against a Reader's trust, so that a mark stays something a machine earned and my typo is not an unappealable penalty. 20. As a Reader, I want a correction to reach me silently as just the Latest Chapter, so that there is no announcement to read and the new-chapter accent simply follows the number. 21. As the owner, I want to repair a Series' page address, so that a row whose URL went bad stops being permanently unpollable. 22. As the owner, I want a repaired address validated before it is stored, so that the server cannot be talked into probing arbitrary hosts from its own network position. 23. As the owner, I want the repair to fetch nothing, so that the page does not imply it verified an address it only stored. 24. As the owner, I want to press *Check now* after a repair, so that verification is an explicit act with a visible result. 25. As the owner, I want the page to say plainly that a Site-wide host change is not this control's job, so that I do not sit typing the same fix into two hundred forms. 26. As a Reader, I want to be unable to repoint a Series' address, so that one Reader cannot move a shared row onto a different work that the same gate would happily accept. 27. As the owner, I want to remove a Series no Reader holds, so that rows nothing can reach stop accumulating and stop holding Cover bytes on a small disk. 28. As the owner, I want the Remove control rendered only when no Reader holds the Series, so that I am never offered a control the database will refuse. 29. As the owner, I want a removal that races a fresh bookmark to fail with a plain explanation, so that the refusal reads as "somebody bookmarked it again" rather than as an error. 30. As the owner, I want Cover bytes reclaimed only when no other Series points at them, so that removing one Series cannot blank another's artwork. 31. As the owner, I want an interrupted reclamation to be findable in one query, so that a partial failure leaves a repairable broken image rather than an unnamed file nothing can reach. 32. As the owner, I want one *Check now* to repair a Series whose Cover file went missing, so that the recovery path is a control I already have. 33. As the owner, I want the removal confirm copy to promise only what is certain, so that it does not claim byte deletion the guard may refuse or claim an effect on Readers who do not exist. 34. As the owner, I want removal confirm-gated, so that the one action that pulls a row out of the list cannot be a single mis-click. 35. As the owner, I want the list's heading count to update when I remove a row, so that a heading still claiming one orphan after the last one is gone does not need a page reload to clear. 36. As the owner, I want removing from the detail page to send me back to the orphan list, so that I am not left refreshing a page whose subject no longer exists. 37. As the owner, I want to know that removing an orphan is not a one-way door, so that I can act without weighing it as data loss. 38. As the owner, I want one line telling me where the current Latest Chapter came from, so that I can judge whether to trust it before I act on it. 39. As the owner, I want that line derived from state that already exists rather than from a history table, so that the dashboard never becomes a record of which Reader read what and when. ## Implementation Decisions ### Cover replacement rides the Forced Poll - **No dedicated control and no second column.** A Forced Poll takes the replace path; an unforced Poll keeps fill-if-blank unchanged. The pass already holds the Cover it read when it decides whether to fill a blank one, so a forced pass costs no extra page read. - Rejected: a second `series.force_cover_at` column — two columns, two verbs, two controls and another field on the admin projection, for a rare action. Rejected: a synchronous fetch inside the admin request — it contradicts the standing decision that intervention reaches the poller through the database, and it would block an owner request on the browser sidecar. - **Accepted consequence, stated rather than hidden**: the owner cannot refresh a Cover without also re-reading chapters. The two are one act — read this Series' page now and accept what it says. - The glossary already carries this: *Forced Poll* states that it takes whatever Cover the Site publishes today, and *Acquisition* no longer claims to be the only read that establishes a Cover. ### The Cover address becomes a hash of the bytes Three facts make a same-address replace impossible: the file is installed with a hard link that ignores an existing target, the covers insert does nothing on conflict, and the public cover route is served immutable for a week. So the address **must** change for a replacement to be visible — and today it is the SHA-256 of the *source URL*. - **New Cover writes are addressed by the SHA-256 of the bytes. Legacy URL-derived rows are left untouched, and there is no migration.** The only contract on the value is the 64-hex-character pattern the store validates, so a mixed derivation is legal with no schema change and legacy rows stay readable. - Rejected: rehashing every stored file. That cannot be a migration at all — migrations are SQL-only, globbed by version — and would need a run-once Go step walking the whole cover tree at boot. Rejected: a URL hash salted with a per-Series refetch generation (a column whose value means nothing to a reader of the row). - **Free consequence, and the reason this is worth doing**: the new address equals the old one *if and only if* the Site serves the same bytes. "Unchanged" becomes observable instead of a silent no-op, and the page can say so. - **Two deletions come with it.** `CoverAddress(sourceURL)` has no production caller — only tests and store internals — so the "name a Cover before you have the bytes" property its doc comment defends is not load-bearing. And the prefetch path's existing-cover shortcut stops matching byte-addressed rows: delete the shortcut and let that legacy heal path fetch, rather than adding a `source_url` column to the covers table to keep an optimisation on a rare repair. - **This needs an ADR.** The rule is hard to reverse once data exists under both derivations, and it contradicts the URL-hash statement in the cover-address migration and in the store's own doc comment, which the change must rewrite. ### Store surface for the replacement `ReplaceSeriesCover(site, seriesID, sourceURL string, body []byte, contentType string) error` beside the existing `SetSeriesCover`. Both go through the same cover installer and differ only in the update predicate: the existing method keeps its empty-address guard. - **Rejected: a `force bool` parameter.** Three existing callers would pass `false` for ever, and the first caller that passes `true` turns fill-if-empty into overwrite at the call site rather than in the store. The existing test that asserts a Cover is not overwritten guards behaviour that should stay unparameterised. - The forced update writes `cover` as well as `cover_address`, because a re-art may sit behind a new source URL. - **Fetch path: the existing one.** Reuse the existing cover fetch with its existing fetcher routing, so the gated cover host and the browser-only Site keep going through the sidecar and everything else goes over plain TLS — one routing rule for Acquisition, Poll and this action. **No pre-flight refusal when the sidecar is down**: the Lane skips its pass, the request marker ages, and an ageing marker is already the evidence that a Lane is stuck. The Lanes page already states the sidecar fact, so no new notification is added. - The Series row says only how old the request is. It never repeats the Lane-level reason — the Lanes page holds that, per the one-figure-in-one-place rule. Rejected: a "no browser" mark on every Series row of a browser Site, which would put Lane state into the Series list. - **No confirmation, no danger accent, never ember.** A newer Cover is what the Site now publishes; a Cover that disagrees with the Site is the state that confuses the Reader, so the change is the remedy and not the risk. The confirm law covers actions that pull a Series out of a list, and this pulls none. ### Latest Chapter correction **The motivating case was not a defect.** The lightnovelworld ticket that prompted this settled the opposite: the chapter-list maximum *is* Latest Chapter, and capping it at the Site's own newest-chapter banner is pre-rejected. A Reader below that maximum genuinely has unread chapters and the accent is earned. So "fixes the class of that issue" is struck. What survives is one class no machine can reach: a Reader-raised value on a Series whose page the Poll **cannot** read. Where the page *is* readable, a Forced Poll is the remedy — the pass judges the outstanding Sighting and rewrites from the Site — so a correction on a readable Series is not a correction, it is a workaround for an adapter bug. **There are two overwriters, not one.** The poller rewrites on any inequality with what the Site publishes; and the series Upsert's conflict clause is unconditional last-write-wins from **any** Reader's PUT, which both userscripts fire on every series-page visit, sending the site-read value up *or* down and sending it even unchanged. So "sticky" was never one guard: it would be a pin column plus a guard on the poller path *and* on the client write path every Reader hits. Decisions: - **Not authoritative. No pin, no floor.** The correction reuses the existing value columns and dies the moment any machine write lands. A floor is rejected outright: it would have made the *downward* direction — the one that was actually right in the motivating case — unrepairable. On the class this control is for, nothing overwrites it anyway; that is what "the Poll cannot read the page" means. - **One numeric input; the label is derived.** The handler requires a finite float greater than zero, else 400. The stored label is `"Chapter " + the formatted number`. Two inputs are rejected: nothing depends on matching a Site's typography, and a free-text label invents a failure mode where the number is right and the panel renders the typo. Leaving the old label is worse still — the panel would keep saying the old chapter after the number moved. - New column `series.latest_corrected_at bigint NOT NULL DEFAULT 0` meaning **"the current value is a human's"**, written by the correction and zeroed by every machine write of the value: - a new `CorrectLatestChapter(site, seriesID string, num float64, ts int64) error` sets the label, the number and the stamp; - the poller's chapter setter gains `, latest_corrected_at = 0` in the same update — already conditional in effect, since the pass returns early on equality and so only writes when the number moved; - the series Upsert gains one clause in its existing conflict update, **conditional on the value actually changing**: `latest_corrected_at = CASE WHEN excluded.latest_chapter_num IS DISTINCT FROM series.latest_chapter_num THEN 0 ELSE latest_corrected_at END`. Unconditional zeroing would be wrong for the common case: after a correction a Reader's cached row holds the corrected number, so an ordinary Progress PUT resends it and would clear the stamp while the value is still the owner's. One clause in an existing statement — no extra round trip, no second update — **but it touches the security-reviewed Upsert, so say so in that PR.** - **This is deliberately not an audit trail**: no history, no `corrected_by` (there is one owner), and a stamp cannot outlive the value it describes. - **A contradicting correction clears the Sighting attribution without judging it.** After a successful correction, if the Series carries a raising Reader, call `ClearSightingAttribution` — **never** `RecordSightingOutcome(..., false)`. Marking the Reader is tempting (this is the one class where a mark would otherwise never land) but recovery is twenty confirming *Polls*, and this control exists precisely because no Poll can read the page — so a mark earned here is permanent in practice and an owner's typo would be unappealable. Marks stay machine-earned. Leaving the attribution in place is wrong outright: the next Poll would credit or blame that Reader for the *owner's* number. - **Corrections are silent to Readers.** No notification. The corrected label simply *is* the Latest Chapter, and the accent follows the number against each Reader's own progress, so a downward correction can only extinguish an accent and an upward one lights it — which is what "the Site published" means. ### Series URL repair - New store method `SetSeriesURL(site, seriesID, url string) error`; the handler validates with the **existing** gate before storing. That gate is currently unexported in the poller, so it is **renamed to `latest.FetchableSeriesURL`** with its in-package callers updated — one implementation, never a second gate. The web package already imports `latest`. - No synchronous fetch in the request. The owner presses *Check now* afterwards. - This lifts the write-once rule the series migration states — *client-supplied values are ignored once the row exists* — **for the owner only**. - **Rejected: letting a Reader's PUT heal a stale URL.** The client already sends a fresh one every time and the store drops it, deliberately. `series` is a **shared** row: one Reader could repoint a Series every other Reader reads at a different work on the same host, and the gate would pass it, because the gate stops SSRF, not mis-pointing. - **Honest limit, stated on the page rather than papered over**: a Site-wide host change invalidates every row of that Site at once, and a per-Series form is the wrong tool for it. That is a SQL migration and is out of scope. - The repair records nothing. It changes what the Poll fetches, not what a Reader reads. ### Orphan removal - **An owner action, one at a time, orphans only.** *Remove* sits on the Series list row and on the detail page, and both render it **only** at a zero Reader count. - Rejected: a permanently-disabled or always-present *Remove* the database refuses — a dead control is one the owner learns to ignore, and the foreign-key refusal is for the race, not for everyday feedback. - Rejected: a bulk sweep over the orphan filter — an unbounded delete over rows the owner has not read individually is a different risk class, and the orphan set is short by construction (a Reader has to drop the last Bookmark). - **The database is the guard.** A plain `DELETE FROM series WHERE site = $1 AND series_id = $2`. The existing bookmarks-to-series foreign key has deliberately no cascade, so it refuses the delete while any Bookmark exists — which *is* the definition of orphan. **No `NOT EXISTS` pre-check**: it would add a read-then-write window and a second copy of the definition that can drift. A foreign-key violation is translated at the handler into "a Reader has bookmarked this Series again" and the row re-rendered with its new count. - **Not a one-way door, and the spec says so**: the next PUT re-inserts the series row and Acquisition or a Poll refetches the Cover. Removing an orphan discards a row nothing reads, not history. - ***Check now* is not offered on an orphan row.** A Forced Poll never overrides the Bookmarks join, so the press would write the request, no Lane would ever consume it, and the row would age a pending marker for ever — firing the stuck-Lane signal on a Series that is not stuck. A row at zero Readers carries *Remove* only. Rejected: making a Forced Poll override the join. A Series with no Reader has no consumer for the result. ### Cover byte reclamation — one guarded helper, two callers `DELETE FROM covers WHERE address = $1 AND NOT EXISTS (SELECT 1 FROM series WHERE cover_address = $1)`, plus the unlink of the sharded path. Called at both causes: orphan removal, and the forced Cover replace whose byte-derived address strands the old one the moment the art changes. `series.cover_address` is the only reference that keeps bytes alive; `series.cover` holds the third-party source URL and references nothing. - **The guard is not optional.** Sharing is rare but real. Measured 2026-08-18 against demonicscans.org: twelve Series from the home page gave twelve distinct cover addresses and twelve distinct byte digests, and a page for a Series that does not exist publishes no cover image at all, so there is no shared placeholder. Rare, not impossible. - **Order: unlink the file first, delete the covers row last.** This inverts the obvious order, and the reason is that **the covers row is the handle.** Keep it until last and an interrupted reclamation is discoverable in SQL — covers rows unreferenced by any series cover address — and re-running finishes the job. Delete the row first and the leftover file is named by nothing: no query lists it and finding it means walking the sharded tree and diffing against the database. That is dark garbage needing manual deletion; the alternative is a repairable broken image. - **An unlink failure other than "already gone" leaves the row in place and logs.** Keeping the handle is what makes a retry possible. - For orphan removal the whole sequence is: delete the series row, then guard-check, then unlink, then delete the covers row — the guard cannot pass while the series row still points at the address. - **A row whose file is gone is repaired by one *Check now***, whether the Site serves the same bytes (same address, file re-linked) or new ones (new address, row repointed). The forced replace path has no empty-address predicate, which is what makes this true; the earlier note that a non-blank cover address is unrepairable described the routine paths only and is superseded. - One hazard ordering cannot fix: a concurrent Forced Poll re-pointing a live Series at that address between guard and unlink. Narrow, and its outcome is the repairable case. - Rejected: a sweep over the whole covers table, owner-triggered or opportunistic. It runs a whole-table predicate — the one whose being wrong is unrecoverable — to fix a problem two known callsites prevent, and it needs a schedule this backend has no ticker for. Rejected: never reclaiming; a Cover is 50–500 KB on a swapless 1974 MiB VPS while a series row is 200 bytes. - **Free consequence**: a sweep, if ever wanted later, is one SQL query over the covers table rather than a tree walk. This spec does not build it. ### Latest Chapter provenance — derived, never recorded **Nothing records how the number came to hold its value, and nothing will.** No history table, no attribution column, no migration. Three actor classes, all derived from state this series already decides: - **Correction** — the correction stamp is non-zero. Means "the number on this row is a human's". - **Sighting** — the Series carries a raising Reader. A Reader's report raised the number and no Poll has judged it; surfaced anonymously as the admin row's Sighting flag. - **Machine read** — neither of the above, with a non-zero check stamp. **Acquisition does not survive as a distinct actor.** Acquisition stamps the check timestamp exactly as a Poll does, so an acquired value and a polled value are indistinguishable the moment the Acquisition finishes. Accepted deliberately: the only case where the difference is actionable — acquired once and never read again — is already the `unchecked` filter, and telling the two apart would need a column, which is the thing this declines to add. **Consequence: the correction stamp is promoted from convenience to load-bearing.** It is the *only* evidence a Correction ever happened, so it must not be dropped, and its zeroing rules are what keep the derivation honest. **Presentation: one line on the Series detail page** naming the actor class beside the number. Owner-only by route. No list-row field (a row has two lines), no new filter name, no landing figure — an actor class is context for a Series you are already looking at, never a population to sweep. **The Cover half is closed by construction, and a superseded Cover is deliberately unrecoverable**: new bytes are addressed by their own hash, so a replacement is visible in the address itself, and the unreferenced covers row is reclaimed. The Site is the only true source of what the art should be. **A history table was designed before it was dropped**, and the design is recorded so it is not re-proposed: one row per *actual* change (a no-change PUT and a confirming Poll writing nothing, roughly 52 rows per Series per year), actor class only, newest-twenty-per-Series pruned on insert in the lazy style the sessions table already uses. Three costs killed it: 1. **A per-Series timestamped table is one column away from the log this project forbids.** Add a Reader id — the obvious next request, since a Sighting comes from someone — and it becomes a record of which Series each Reader reads and when. 2. **It would duplicate a fact with a second writer.** "The current value is a human's" would live in both the correction stamp and the newest row's actor class, and the two would eventually disagree. 3. **No owner question needs the past.** The Poll is the oracle, so the next successful read re-establishes the number without reference to history; distrust of a Reader is already the Sighting-disagreement counter, which is durable, judged by Polls, and self-healing over twenty agreements. A trail's unique payload was "who to distrust", and that mechanism already exists and is better. ### Routes and presentation | Route | Body | Notes | |---|---|---| | `POST /admin/series/{key}/latest` | `num` | finite float > 0, else 400 | | `POST /admin/series/{key}/series-url` | `url` | must pass `latest.FetchableSeriesURL`, else 400 | | `POST /admin/series/{key}/remove` | — | orphans only; the FK is the guard | All join the admin route list behind the owner gate, and form bodies are capped the way the API path caps them. - **No predicate gate on the correction controls.** Both sit on the detail page unconditionally, with copy saying the next successful Poll overwrites the value. Gating on "looks unverifiable" would make this wait on per-Series failure state for cosmetics, and every gate variant forbids the repair exactly when it gets easy — a briefly-healed Site does not make a wrong stored number right. - Correction and repair are **unconfirmed** (the confirm law gates what pulls a Series out of the list, and neither does), never ember, no danger accent. A `corrected <age> ago` marker renders while the stamp is non-zero; it is transient by design and must not read like history. - Removal is **confirm-gated** with the in-place confirm row on the danger wash. **Confirm copy states only what is certain**: *"Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row."* Byte deletion is not promised — the guard decides. The earlier draft copy claiming it removes the Series "for every Reader" and deletes its Cover bytes is wrong twice over: an orphan has no Readers, and the bytes may survive the guard. - **Sharing is not disclosed and a failed unlink gets no surface.** No "shared with N other series" line: it costs a query per confirm-row render and buys a fact the owner cannot act on. No "unreclaimed covers: N" figure for a state that has never occurred and that the page cannot fix. A failed unlink is logged, and the ordering above makes it findable. - **Responses.** The list row answers with the removed row's fragment plus a second out-of-band snippet re-rendering the `<N> series · <label>` heading — the row and the count are one fact, and a heading still reading 1 after the last orphan is gone is a lie the owner must reload to clear. The filter select's option counts stay stale until the next navigation: eight snippets per delete to keep one number honest is not worth it. The detail page redirects to the orphan list — the subject no longer exists, a refresh would 404, and the list is where the owner was headed. - Detail page layout: the two correction forms sit in the two-column grid the dashboard spec reserved, with *Check now* below; the provenance line sits beside the number. ## Testing Decisions **What makes a good test here**: it asserts an outcome the owner or a Reader can observe — a stored value after a request, a rendered marker, a file's presence, a refusal's status code — and it fails on a plausible bug. Nothing asserts a helper's internals or a log line. **The primary seam is unchanged and existing: the router** over a real store and a throwaway Postgres, driven with an owner session cookie. Every control is reached as a POST and asserted through the response and the resulting database state. No new seam, no new fake. Modules and coverage: - **Router / web** (prior art: the existing admin roster and owner-gate tests): each new route is owner-gated by joining the route list; a non-numeric, zero or negative chapter is 400 and does not reach the store; a URL failing the gate is 400 and does not reach the store; *Remove* is rendered only at a zero Reader count; a removal that hits the foreign key renders "a Reader has bookmarked this Series again" with the fresh count rather than a 500; the removal response carries the re-rendered heading count; the detail page redirects after removal; the `corrected <age> ago` marker appears while the stamp is set and is gone after a machine write; the provenance line names each of the three actor classes for the three states that produce them. - **Store** (prior art: the existing store tests over a throwaway Postgres with real migrations): `CorrectLatestChapter` stamps and the poller's chapter setter zeroes; an Upsert with the *same* number keeps the stamp and one with a *different* number zeroes it (this pair is the whole point of the conditional clause and is the most likely thing to get wrong); a correction clears the raising Reader and leaves both Sighting counters untouched; `ReplaceSeriesCover` overwrites a non-empty address while the existing setter still refuses to (the existing no-overwrite test must keep passing unchanged — it is the guard that this change did not leak into the routine path); the same bytes yield the same address and different bytes a different one; the reclamation helper deletes the covers row and the file when nothing points at the address, and deletes **neither** when a second Series does; the delete of a series row is refused while a Bookmark exists and succeeds when none does; `SetSeriesURL` writes where the Upsert would not. - **Poller** (prior art: the round-at-a-time pass tests with a fake fetcher and an injected clock): a forced pass replaces an existing Cover and an unforced pass does not; a forced pass over identical bytes is a no-op the caller can distinguish; a forced pass on a Series whose Cover file was unlinked re-links or repoints the row; the Cover fetch routes through the same fetcher selection as the page read. - **Filesystem behaviour is asserted, not assumed**: the reclamation test checks the sharded path is actually gone, and the interrupted case (row present, file absent) is asserted to be findable by the unreferenced-covers query and repaired by a forced pass. ## Out of Scope - **A Latest Chapter pin or floor.** Rejected on the merits above; the Poll is the oracle. - **An authoritative hand-set value of any kind**, including one that survives a Reader's PUT. - **Reader-side repair of a series URL.** `series` is shared; the gate stops SSRF, not mis-pointing. - **Bulk rewrite of series URLs across a whole Site.** A host change invalidates every row of a Site at once. That is a SQL migration, and the dashboard's library-wide job is *finding* broken rows. - **A bulk orphan sweep**, and any owner-triggered or scheduled sweep over the covers table. - **A dedicated Cover-refetch control, a `force_cover_at` column, or a synchronous Cover fetch in the request.** - **Rehashing existing Cover addresses.** Legacy URL-derived rows stay as they are, for ever. - **Any Latest Chapter history, provenance column, or audit trail** — including a "went backwards" filter, whose first ground (nothing stores the previous number) stays true, and a flap detector, which was reachable only under the history option and dies with it. - **Notifying Readers of a correction or a replaced Cover.** - **Recovering a superseded Cover.** Deliberate: the Site is the only true source of what the art should be. - **A "shared with N other series" disclosure or an unreclaimed-covers figure.** ## Further Notes - **Migration numbers are claimed in landing order.** Measured 2026-08-21: the tree's migrations and ADRs both stop at 0011 and everything the map specified is unbuilt. This spec's only schema work is `series.latest_corrected_at bigint NOT NULL DEFAULT 0`; take the next free number after the dashboard spec's migrations. - **One ADR is required with the implementation**: Cover addresses derived from the bytes rather than the source URL. It is hard to reverse once data exists under both derivations, and it contradicts statements the change must rewrite in the cover-address migration and the store's doc comment. The reclamation rule needs no ADR — it is one helper and reversible. - **Glossary**: the two edits this work implies already landed while charting — *Forced Poll* states it takes whatever Cover the Site publishes today, *Acquisition* no longer claims to be the only read that establishes a Cover, and *Correction* is defined. Nothing new is needed. - **This spec constrains the next two.** A human correction must **not** clear any per-Series failure state that a later spec adds: an owner typing a number is not evidence the page became readable, and only a successful Poll may reset it. Any new per-Series table must key on `(site, series_id)` with `ON DELETE CASCADE` to `series`, so orphan removal stays a single statement and the FK-as-orphan-test property survives — the cascade must reach poll state only, **never** `bookmarks`, whose refusal *is* the guard. - Security-critical surfaces touched: the fetch gate (renamed and now called from a second package — one implementation, no second gate), the security-reviewed series Upsert (one new conditional clause), and the owner gate. Say which invariant you preserved in the PR and run the full backend test suite before calling it done.
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#135