Data-correction actions: Latest Chapter and series_url repair #121

Closed
opened 2026-08-17 15:22:45 +07:00 by sulthan · 3 comments
Owner

Part of #114
Blocked by: #115, #117

Question

Can the owner correct wrong Series data by hand, and under what constraints?

This is the intervention with real teeth: issue #79 has lightnovelworld reporting a chapter-list
maximum of 1317 against the Site's own newest-chapter indicators of 1298, and every Reader of that
Series sees the wrong number. Candidates from charting: correct a wrong Latest Chapter; repair or
re-discover a bad series_url; delete an orphaned Series row.

Hard constraints: any outbound fetch of a client-supplied URL must pass fetchableSeriesURL
first, so a repaired series_url is exactly the input that gate exists for. Series identity is
discovered, not derived (ADR-0008).

To decide:

  • Whether a hand-set Latest Chapter is authoritative, and what stops the next poll from
    overwriting it — a sticky flag, a floor, or nothing at all.
  • Whether the correction is free-text or a pick from what the Site currently offers. Free text is
    the only thing that fixes #79, and it is also the one place a human types into a row every
    Reader reads.
  • Whether the Sighting record is touched when a correction contradicts a Reader-raised number
    (ClearSightingAttribution, RecordSightingOutcome exist).
  • Whether orphaned-Series deletion is in scope here at all, given the bookmarks FK to series
    has no cascade.
  • What is recorded about who changed what and when — which is why this waits on the poll-history
    decision.
Part of #114 Blocked by: #115, #117 ## Question Can the owner correct wrong Series data by hand, and under what constraints? This is the intervention with real teeth: issue #79 has lightnovelworld reporting a chapter-list maximum of 1317 against the Site's own newest-chapter indicators of 1298, and every Reader of that Series sees the wrong number. Candidates from charting: correct a wrong Latest Chapter; repair or re-discover a bad `series_url`; delete an orphaned Series row. Hard constraints: any outbound fetch of a client-supplied URL must pass `fetchableSeriesURL` first, so a repaired `series_url` is exactly the input that gate exists for. Series identity is discovered, not derived (ADR-0008). To decide: - Whether a hand-set Latest Chapter is authoritative, and what stops the next poll from overwriting it — a sticky flag, a floor, or nothing at all. - Whether the correction is free-text or a pick from what the Site currently offers. Free text is the only thing that fixes #79, and it is also the one place a human types into a row every Reader reads. - Whether the Sighting record is touched when a correction contradicts a Reader-raised number (`ClearSightingAttribution`, `RecordSightingOutcome` exist). - Whether orphaned-Series deletion is in scope here at all, given the `bookmarks` FK to `series` has no cascade. - What is recorded about who changed what and when — which is why this waits on the poll-history decision.
sulthan added the wayfinder:grilling label 2026-08-17 15:22:45 +07:00
Author
Owner

From #119 (closed): a Forced Poll re-judges any outstanding Sighting for free — checkOne already calls judgeSighting, and force overrides the deferral clause — so Check now is itself the manual remedy for a Latest Chapter a Reader raised wrongly, and it can mark that Reader. Whether the correction UI advertises it that way, or offers repair as a separate control, is this ticket's call. The action route shape (POST /admin/series/{key}/<verb>, answer with the swapped row fragment) is settled there too.

From #119 (closed): a Forced Poll re-judges any outstanding Sighting for free — `checkOne` already calls `judgeSighting`, and force overrides the deferral clause — so *Check now* is itself the manual remedy for a Latest Chapter a Reader raised wrongly, and it can mark that Reader. Whether the correction UI advertises it that way, or offers repair as a separate control, is this ticket's call. The action route shape (`POST /admin/series/{key}/<verb>`, answer with the swapped row fragment) is settled there too.
Author
Owner

Note from #118 (closed):

The class this ticket repairs is invisible to every filter, by proof rather than by omission.
#118 rejected a "Latest Chapter went backwards" filter: nothing stores the previous value, and a
downward write is the correction rather than the fault (poller_test.go:481-497 seeds 400 and
asserts 296 after a poll). More to the point, the case that actually costs something — #79's
adapter reading a stable wrong 1317 every hour — never moves, so no movement-based filter could
ever fire on it.

So hand repair carries that whole class alone, and the only automated hint the owner gets is
sighting-raised (latest_sighted_at > latest_checked_at): the stored value came from a Reader's
report rather than a Poll. Worth deciding here whether the correction UI is reachable from that
filter, since it is the closest thing to a worklist this class will ever have.

Note from #118 (closed): The class this ticket repairs is **invisible to every filter**, by proof rather than by omission. #118 rejected a "Latest Chapter went backwards" filter: nothing stores the previous value, and a downward write is the *correction* rather than the fault (`poller_test.go:481-497` seeds 400 and asserts 296 after a poll). More to the point, the case that actually costs something — #79's adapter reading a stable wrong 1317 every hour — never moves, so no movement-based filter could ever fire on it. So hand repair carries that whole class alone, and the only automated hint the owner gets is `sighting-raised` (`latest_sighted_at > latest_checked_at`): the stored value came from a Reader's report rather than a Poll. Worth deciding here whether the correction UI is reachable from that filter, since it is the closest thing to a worklist this class will ever have.
sulthan self-assigned this 2026-08-18 08:59:04 +07:00
Author
Owner

Resolved. Both verbs exist, neither is authoritative, and the premise this ticket was charted on
does not survive a read of #79.

The premise was wrong

#79 is not a defect. Its own body settles the opposite — "Latest Chapter is the highest
chapter number the Site lists for a Series"
, therefore "the current behaviour is correct …
No code changes"
— and its Do not "fix" this later section pre-rejects capping the maximum at
the top list entry. 1317 is right; a Reader at 1298 genuinely has 19 unread chapters and the ember
is earned. So there is no known case of a wrong stored Latest Chapter on the tracker, and the map's
"fixes the class of #79" is struck.

What survives is one class no machine can reach: a Reader-raised value (latest_raised_by
non-NULL) on a Series whose page the Poll cannot read. #119's Forced Poll is the remedy
whenever the page is readable — checkOne calls judgeSighting and rewrites from the Site — so
a correction on a readable Series is not a correction, it is a workaround for an adapter bug.
That framing is the whole shape of what follows.

Two overwriters, not one

The ticket assumed the Poll. There are two:

  • checkOne (poller.go:474-485) rewrites on any inequality with what the Site publishes.
  • the series Upsert's ON CONFLICT DO UPDATE SET latest_chapter=excluded.latest_chapter, latest_chapter_num=excluded.latest_chapter_num (store.go:864-867) — unconditional
    last-write-wins from any Reader's PUT, and reportLatestChapter
    (manga-bookmark.user.js:917-942, novel-bookmark.user.js:823) fires on every series-page visit,
    sends the site-read value up or down, and sends it even unchanged (that is the Sighting).

So "sticky" was never one guard: it is a pin column plus a guard on the poller path and on the
client write path every Reader hits.

Decisions

1. A hand-set Latest Chapter is not authoritative. No pin, no floor. The correction reuses the
existing value columns and dies the moment any machine write lands. Rationale: the Poll is the
oracle (judgeSighting's premise), a page read is stronger evidence than a typed number, and being
overwritten by one is the correct outcome. On the class this control is for, nothing overwrites it
anyway — that is what "the Poll cannot read the page" means. A floor is rejected outright: it would
have made #79's downward direction — the one that was actually right — unrepairable.

2. One numeric input; the label is derived. The owner types a number only. Handler requires a
finite float > 0, else 400. The stored label is written as
"Chapter " + strconv.FormatFloat(num, 'f', -1, 64) → Chapter 1298, Chapter 1298.5. Two inputs
(label + number) 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
(manga-bookmark.user.js:1377 renders latest_chapter, :1373-1375 compares
latest_chapter_num). Leaving the old label is worse still — the panel would keep saying
Chapter 1317 after the number moved.

3. series_url repair is owner-typed and gated. New store method
SetSeriesURL(site, seriesID, url string) error; the handler validates with the existing gate
before storing. fetchableSeriesURL (poller.go:555) is unexported, so it is renamed to
latest.FetchableSeriesURL
with its in-package callers updated — one implementation, never a
second gate; internal/web/admin.go already imports latest. No synchronous fetch in the request:
the owner presses Check now (#119) afterwards. This lifts the write-once rule that
migration 0002 states ("client-supplied values are ignored once the row exists") for the owner
only.

Rejected: letting a Reader's PUT heal a stale series_url (the client already sends a fresh one
every time and the store drops it). series is a shared row — one Reader could repoint a
Series every other Reader reads at a different work on the same host, and FetchableSeriesURL
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 (asuracomic →
asurascans) invalidates every row of that Site at once, and a per-Series form is the wrong tool for
it. That is a migration, and it is now a fog line on the map, not a promise of this control.

4. latest_corrected_at means "the current value is a human's", and is transient.
New column, migration 0015 (after #116's 0012, #119's 0013, #117's 0014):

ALTER TABLE series ADD COLUMN latest_corrected_at bigint NOT NULL DEFAULT 0;

Written by the correction, zeroed by every machine write of the value:

  • new CorrectLatestChapter(site, seriesID string, num float64, ts int64) error — sets
    latest_chapter, latest_chapter_num, latest_corrected_at = ts.
  • SetLatestChapter gains , latest_corrected_at = 0 in the same UPDATE. Already conditional in
    effect: checkOne returns early on equality, so the Poll only writes when the number moved.
  • the series Upsert gains one clause in its existing DO 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 does touch 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. The map's provenance patch still owns the audit
question, which now spans three unrecorded writers — a correction, #120's replaced Cover, and
SetLatestChapter lowering a number unconditionally.

The series_url repair records nothing. It changes what the Poll fetches, not what a Reader reads.

5. A contradicting correction clears the Sighting attribution without judging it. After a
successful correction, if latest_raised_by is non-NULL call
ClearSightingAttribution(site, seriesID, *raisedBy) — never RecordSightingOutcome(..., false).
Marking the Reader is tempting (this is the one class where a mark would otherwise never land) but
recovery is 20 confirming Polls (SightingAgreementsToClear), 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.

6. No predicate gate; the page words the stopgap. Both controls sit on
GET /admin/series/{key} unconditionally, with copy saying the next successful Poll overwrites the
value. Gating on "looks unverifiable" would make this ticket wait on #127 for cosmetics, and both
gate variants forbid the repair exactly when it gets easy — a briefly-healed Site does not make a
wrong stored number right.

Routes, in #115's shape (POST /admin/series/{key}/<verb>, answering with the swapped fragment —
here the detail fragment, since neither control is on the list row):

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

Both join adminRoutes() behind requireOwner; form bodies capped with http.MaxBytesReader as
the API path does. No .confirm-row — Cinder gates what pulls a Series out of the list, and
neither verb does. Never --ember, no --danger.

7. Corrections are silent to Readers. No notification, no announcement: the corrected label
simply is the Latest Chapter, and the ember follows latest_chapter_num against that Reader's own
last_chapter_num (manga-bookmark.user.js:1373-1375), so a downward correction can only
extinguish an ember and an upward one lights it — which is what "the Site published" means. With
#120's Cover half, the map's are admin corrections visible to Readers patch is fully decided and
comes off Not yet specified.

8. Orphan-Series deletion leaves this ticket. #125 owns it outright, and #120 already routed
Cover-byte reclamation there. Struck from the candidate list here; nothing about it decided.

Consequences for the map

  • #127: a correction must not clear a failure counter or a last-outcome column. An owner
    typing a number is not evidence the page became readable; only a successful Poll is. (Answers one
    of #127's open questions in the negative, from this side.)
  • #116 / #118: nothing pushed. No ninth filter (the vocabulary stays at eight), no new
    SeriesRow field, list row unchanged. The worklist into this control is
    ?filter=sighting-raised, which #118 already calls the only suspicion lens this class will get.
  • #122: one numeric input, one URL input, an unconfirmed submit each, a stopgap line of copy,
    and a corrected <age> ago marker while latest_corrected_at > 0.

Invariants worth a test

  • CorrectLatestChapter stamps; SetLatestChapter zeroes.
  • Upsert with the same number keeps the stamp; with a different number zeroes it.
  • A URL failing FetchableSeriesURL is rejected 400 and does not reach the store.
  • A correction clears latest_raised_by and leaves both readers.sighting_* counters untouched.
Resolved. Both verbs exist, neither is authoritative, and the premise this ticket was charted on does not survive a read of #79. ## The premise was wrong #79 is **not a defect**. Its own body settles the opposite — *"Latest Chapter is the highest chapter number the Site lists for a Series"*, therefore *"the current behaviour is correct … No code changes"* — and its **Do not "fix" this later** section pre-rejects capping the maximum at the top list entry. 1317 is right; a Reader at 1298 genuinely has 19 unread chapters and the ember is earned. So there is no known case of a wrong stored Latest Chapter on the tracker, and the map's "fixes the class of #79" is struck. What survives is one class no machine can reach: a Reader-raised value (`latest_raised_by` non-NULL) on a Series whose page the Poll **cannot** read. #119's Forced Poll is the remedy whenever the page *is* readable — `checkOne` calls `judgeSighting` and rewrites from the Site — so a correction on a readable Series is not a correction, it is a workaround for an adapter bug. That framing is the whole shape of what follows. ## Two overwriters, not one The ticket assumed the Poll. There are two: - `checkOne` (poller.go:474-485) rewrites on any inequality with what the Site publishes. - the series Upsert's `ON CONFLICT DO UPDATE SET latest_chapter=excluded.latest_chapter, latest_chapter_num=excluded.latest_chapter_num` (store.go:864-867) — unconditional last-write-wins from **any Reader's PUT**, and `reportLatestChapter` (manga-bookmark.user.js:917-942, novel-bookmark.user.js:823) fires on every series-page visit, sends the site-read value up *or* down, and sends it even unchanged (that is the Sighting). So "sticky" was never one guard: it is a pin column plus a guard on the poller path *and* on the client write path every Reader hits. ## Decisions **1. A hand-set Latest Chapter is not authoritative. No pin, no floor.** The correction reuses the existing value columns and dies the moment any machine write lands. Rationale: the Poll is the oracle (`judgeSighting`'s premise), a page read is stronger evidence than a typed number, and being overwritten by one is the correct outcome. On the class this control is for, nothing overwrites it anyway — that is what "the Poll cannot read the page" means. A floor is rejected outright: it would have made #79's *downward* direction — the one that was actually right — unrepairable. **2. One numeric input; the label is derived.** The owner types a number only. Handler requires a finite float `> 0`, else `400`. The stored label is written as `"Chapter " + strconv.FormatFloat(num, 'f', -1, 64)` → `Chapter 1298`, `Chapter 1298.5`. Two inputs (label + number) 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 (`manga-bookmark.user.js:1377` renders `latest_chapter`, `:1373-1375` compares `latest_chapter_num`). Leaving the old label is worse still — the panel would keep saying `Chapter 1317` after the number moved. **3. `series_url` repair is owner-typed and gated.** New store method `SetSeriesURL(site, seriesID, url string) error`; the handler validates with the **existing** gate before storing. `fetchableSeriesURL` (poller.go:555) is unexported, so it is **renamed to `latest.FetchableSeriesURL`** with its in-package callers updated — one implementation, never a second gate; `internal/web/admin.go` already imports `latest`. No synchronous fetch in the request: the owner presses *Check now* (#119) afterwards. This lifts the write-once rule that migration 0002 states (*"client-supplied values are ignored once the row exists"*) for the owner only. Rejected: letting a Reader's PUT heal a stale `series_url` (the client already sends a fresh one every time and the store drops it). `series` is a **shared** row — one Reader could repoint a Series every other Reader reads at a different work on the same host, and `FetchableSeriesURL` 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 (asuracomic → asurascans) invalidates every row of that Site at once, and a per-Series form is the wrong tool for it. That is a migration, and it is now a fog line on the map, not a promise of this control. **4. `latest_corrected_at` means "the current value is a human's", and is transient.** New column, migration **0015** (after #116's 0012, #119's 0013, #117's 0014): ALTER TABLE series ADD COLUMN latest_corrected_at bigint NOT NULL DEFAULT 0; Written by the correction, zeroed by every machine write of the value: - new `CorrectLatestChapter(site, seriesID string, num float64, ts int64) error` — sets `latest_chapter`, `latest_chapter_num`, `latest_corrected_at = ts`. - `SetLatestChapter` gains `, latest_corrected_at = 0` in the same UPDATE. Already conditional in effect: `checkOne` returns early on equality, so the Poll only writes when the number moved. - the series Upsert gains one clause in its existing `DO 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 does touch 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. The map's provenance patch still owns the audit question, which now spans three unrecorded writers — a correction, #120's replaced Cover, and `SetLatestChapter` lowering a number unconditionally. The `series_url` repair records nothing. It changes what the Poll fetches, not what a Reader reads. **5. A contradicting correction clears the Sighting attribution without judging it.** After a successful correction, if `latest_raised_by` is non-NULL call `ClearSightingAttribution(site, seriesID, *raisedBy)` — never `RecordSightingOutcome(..., false)`. Marking the Reader is tempting (this is the one class where a mark would otherwise never land) but recovery is 20 confirming **Polls** (`SightingAgreementsToClear`), 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. **6. No predicate gate; the page words the stopgap.** Both controls sit on `GET /admin/series/{key}` unconditionally, with copy saying the next successful Poll overwrites the value. Gating on "looks unverifiable" would make this ticket wait on #127 for cosmetics, and both gate variants forbid the repair exactly when it gets easy — a briefly-healed Site does not make a wrong stored number right. Routes, in #115's shape (`POST /admin/series/{key}/<verb>`, answering with the swapped fragment — here the detail fragment, since neither control is on the list row): | 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 | Both join `adminRoutes()` behind `requireOwner`; form bodies capped with `http.MaxBytesReader` as the API path does. No `.confirm-row` — Cinder gates what pulls a Series out of the list, and neither verb does. Never `--ember`, no `--danger`. **7. Corrections are silent to Readers.** No notification, no announcement: the corrected label simply is the Latest Chapter, and the ember follows `latest_chapter_num` against that Reader's own `last_chapter_num` (manga-bookmark.user.js:1373-1375), so a downward correction can only extinguish an ember and an upward one lights it — which is what "the Site published" means. With #120's Cover half, the map's *are admin corrections visible to Readers* patch is fully decided and comes off **Not yet specified**. **8. Orphan-Series deletion leaves this ticket.** #125 owns it outright, and #120 already routed Cover-byte reclamation there. Struck from the candidate list here; nothing about it decided. ## Consequences for the map - **#127**: a correction must **not** clear a failure counter or a last-outcome column. An owner typing a number is not evidence the page became readable; only a successful Poll is. (Answers one of #127's open questions in the negative, from this side.) - **#116 / #118**: nothing pushed. No ninth filter (the vocabulary stays at eight), no new `SeriesRow` field, list row unchanged. The worklist into this control is `?filter=sighting-raised`, which #118 already calls the only suspicion lens this class will get. - **#122**: one numeric input, one URL input, an unconfirmed submit each, a stopgap line of copy, and a `corrected <age> ago` marker while `latest_corrected_at > 0`. ## Invariants worth a test - `CorrectLatestChapter` stamps; `SetLatestChapter` zeroes. - Upsert with the *same* number keeps the stamp; with a *different* number zeroes it. - A URL failing `FetchableSeriesURL` is rejected `400` and does not reach the store. - A correction clears `latest_raised_by` and leaves both `readers.sighting_*` counters untouched.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sulthan/mangaBookmark#121