Map: a proper owner admin dashboard #114

Closed
opened 2026-08-17 15:21:29 +07:00 by sulthan · 1 comment
Owner

Destination

A spec for a proper owner admin dashboard — locked decisions plus enough written detail
that later sessions can implement it without re-deciding anything. The dashboard's job, in
priority order: observability (is the poller healthy, what happened while I was away),
intervention (act on one Series or Reader), library-wide hygiene (find broken rows
across all Readers). Not built here; charted and specified here.

Notes

Domain: the self-hosted Go backend for BookmarkManager (manga + novel read-progress tracking),
its owner-only web surface, and the Poll Lanes that keep Latest Chapter current.

Skills every session on this map should consult: /grilling and /domain-modeling by default;
/prototype for the visual-surface ticket; /research for research tickets.

Standing decisions already settled while charting (do not relitigate):

  • Single owner. requireOwner (backend/internal/web/admin.go) stays the whole access model
    — no roles, no second admin.
  • The admin surface is an extension of Cinder, negotiated in the Claude Design project
    BookmarkManager Web UI, not a second visual language. One narrow 760px prose column cannot
    carry an operations view, but cards/corners/shadows and a rival token set stay out.
  • Privacy boundary: Series-level facts plus an anonymous Reader count. The owner never sees
    which Reader reads what, or anyone's progress list. DueForLatestCheck (store.go) already
    computes a readerCount this way — same shape.
  • Intervention reaches the poller through the database, never by commanding it. The admin
    page reads Lane state from the database too (#117 persists it and deletes
    Poller.laneStates, LaneStatus() and LaneReporter), so the whole surface stays testable
    with no poller running — a test inserts a row rather than constructing a fake. A forced action
    is a flag the Lane notices on its next pass, and the UI promises exactly that rather than
    pretending to be synchronous.
  • The page leads with one summary line aggregating Lane state and Series staleness. Being
    able to tell at a glance whether to keep reading is most of what "proper dashboard" means.

Facts established while charting (2026-08-17, from the code):

  • Today /admin is two blocks: templates/lanes.html (per-Site Lane status, htmx 30s refresh)
    and templates/readers.html (roster, revoke sessions, clear marks).
  • No poll history is persisted anywhere. Lane state lives in Poller.laneStates and is lost
    on restart; the only per-poll DB writes are MarkLatestChecked, SetLatestChapter,
    RecordSighting*, SetSeriesCover. There is no per-Series failure counter column.
  • No generic all-Series query exists. store.List/Get are per-readerID only;
    DueForLatestCheck is the sole cross-reader join. A Series list view needs a new read model.
  • SetSeriesCover only fills an empty cover_address, so a cover refetch needs a force path
    in the store, not merely a new caller.
  • series carries latest_sighted_at and latest_raised_by (FK to readers) — per-Reader
    attribution already sits on a shared row.

Tickets

  • #115 Admin dashboard information architecture and route map
  • #116 Cross-Series read model and the privacy boundary
  • #117 Poll history: persist it, or stay a live-now view
  • #118 Hygiene filters for the Series view
  • #119 Poller command seam: force-poll and pause-lane through the database
  • #120 Per-Series cover refetch and the store's force path
  • #121 Data-correction actions: Latest Chapter and series_url repair
  • #122 Prototype: the Cinder admin surface
  • #123 Research: how far htmx and the existing filter.js carry a filterable Series list
  • #124 Series-level finished state, and the fate of the finished Lifecycle bucket
  • #125 Orphan Series: is removal an owner action, and what happens to the Cover bytes
  • #127 Per-Series Poll outcome state and the failing filter
  • #129 Research: which Sites publish a machine-readable completion marker
  • #130 Completion-marker hint: storage, presentation, and false-positive containment
  • #131 Latest Chapter provenance: what records that the number moved
  • #132 Notification outside the page: is the dashboard pull-only?

Decisions so far

  • Research: how far htmx and the existing filter.js carry a filterable Series list — nothing new needed: vendored htmx 2.0.4 covers URL-addressable filters (hx-push-url), debounced search, load-more/numbered paging, per-row swaps and confirm-gating. But static/filter.js is a client-side title filter, not URL-addressable, and is not loaded on /admin — so the Series list filters server-side. Note on branch research/htmx-series-list.

  • Admin dashboard information architecture and route map — four pages behind one nav row (/admin landing, /admin/lanes, /admin/readers, /admin/series, /admin/series/{key} keyed site:series_id), all joining adminRoutes(). Split rule: landing shows library shape from the database, the Lanes page shows poller liveness from memory — no figure on both. Lanes keeps the only timer (defaultRest = time.Hour, so the new pages have nothing to re-fetch); new actions post to /admin/series/{key}/<verb> and answer with the swapped block. Landing leads with <verdict> · N series waiting · M unchecked over 24h, then a stats block whose hygiene counts link into /admin/series?filter=…. Pushes a per-Site 24h failure count onto #117 and a grouped per-Site aggregate onto #116.

  • Cross-Series read model and the privacy boundary — two store methods (AdminSeries(SeriesFilter) ([]SeriesRow, total, error) for rows, SeriesStats(staleBefore) for the landing aggregate) over a new SeriesRow type that has no field for latest_raised_by — the privacy boundary is the projection, not a template, and attribution surfaces only as an anonymous SightingRaised bool computed in SQL. LEFT JOIN bookmarks, so orphan Series (reader_count = 0) become visible instead of hidden as the Lane hides them; Reader count is a plain COUNT, which knowingly disagrees with the Lane's status <> 'finished' test until #124 removes that bucket. Seven URL-addressable filter names (no-cover, unchecked, stale, unpollable, sighting-raised, orphan, none) as compile-time predicate constants plus ?site=/?kind= params; unpollable means series_url = '' only. 50 rows a page with COUNT(*) OVER () as the total and an empty-page re-read at page 1. Stale is 12h, superseding the 24h figure in #115. Index series (latest_checked_at) in migration 0012, stated as a judgement to re-time.

  • Poller command seam: force-poll and pause-lane through the database — two pieces of database state, no command channel. Forced Poll is series.force_poll_at (migration 0013), pending derived as force_poll_at > latest_checked_at and self-clearing on checkOne's stamp-before-fetch, with no expiry — an ageing request is the evidence a Lane is stuck. It overrides the waiting rules (rest cutoff, Sighting deferral, finished-only bucket) and wakes a sleeping Chrome, never the refusal backoff, the gap, an empty series_url or the bookmarks join. Paused Lane is a poll_lanes(site, paused_until) row with a mandatory expiry (1h/6h/24h), read at the top of runLanePass, surviving restarts; no global runtime pause — LATEST_CHAPTER_POLL_ENABLED plus a redeploy stays that. Acquisition is untouched by a pause. UI: Check now on both the list row and the detail page from one fragment, unconfirmed, check requested <age> ago, no ETA; the Lanes page reads pause from the database, since memory is empty after a restart while a pause is not. Pushes force_poll_at onto #116's projection, two controls onto #122, and the Sighting-remedy wording onto #121.

  • Poll history: persist it, or stay a live-now view — one row per Lane Pass, append-only, ~120 rows/day: poll_passes (migration 0014) carrying due/checked/gap_ms/clamped, a nine-value skip enum (one per runLanePass return path, so due > 0 AND checked = 0 AND skip = '' is the only true stall), and five named outcome counts (refused/unreachable/no_chapter/unfetchable/errors) mapped from the classification readSeriesPage already makes; success is derived, never stored, and a zero renders as none observed. Poller.laneStates, LaneStatus(), LaneReporter and Poller.refuseUntil are deleted — refusal becomes durable poll_lanes.refuse_until (the Site's mood outlives our process) while browserDownAt stays in memory (our own reach does not), and sidecar reachability is derived from unreachable inside latest.RefuseBackoff. Retention is delete-on-insert, 14 days, pruned by the Lane goroutine — no time.Ticker enters a backend that has none. One ownerWindow = 12 * time.Hour feeds both the staleness cutoff and the outcome window, retiring #116's staleAfter. #115's split rule is void (its premise was the deleted memory) and becomes landing aggregates over Sites, Lanes page shows per-Lane detail. Amends #119: poll_lanes becomes the per-Site state row, so ResumeLane zeroes paused_until instead of deleting. Audit trail not subsumed; the per-Series hole is #127.

  • Hygiene filters for the Series view — the filter vocabulary closes at eight: #116's seven plus no-chapter (latest_chapter_num IS NULL AND latest_checked_at > 0 AND series_url <> ''), a Series the poller has attempted and never once read a chapter from — durable today, no column, no write on the poll path, and not subsumed by #127's failing (never worked vs worked for a year and broke on Tuesday). A "Latest Chapter went backwards" filter is rejected: nothing stores the previous value, and a downward write is the correction (poller_test.go:481-497 asserts 400 -> 296), so it would flag the Series that just healed while a stable wrong number never fires it at all; sighting-raised stays the only suspicion lens and #121 the repair. Presentation: one display label per filter, ordered permanent-and-fixable first; every figure in the landing stats block links to what it counts (total, kind split, Site label, roster — not just hygiene counts) except a zero, which prints the digit unlinked rather than opening an empty list; one filter at a time with ?site=/?kind= stacking, heading <N> series · <label>, and an empty state naming the filter it is empty for. No aggregate problem count — the classes overlap, so a sum over-reports. #117's five per-Site outcome counts stay unlinked (the pass row holds counts, never identities) until #127, then all five point at ?site=…&filter=failing. Row field layout and density stay #122's call.

  • Per-Series cover refetch and the store's force path — the owner replaces a Cover by asking for a Forced Poll: no second column, no dedicated control, no synchronous fetch, and checkOne swaps fillBlankCover for a replace when the pass was forced (an unforced Poll still only fills a blank). New cover writes are addressed by the SHA-256 of the bytes while legacy URL-hashed rows are left untouched — legal with no migration because coverAddressRe is the only contract on the value, and a rehash could not be one anyway (migrations are SQL-only). That makes a re-art behind an unchanged URL visible and makes identical art an honest no-op the page can report, instead of the double silent no-op os.Link+ON CONFLICT DO NOTHING produce today; CoverAddress's name-it-before-the-bytes property has no production caller, and prefetchCover's GetCover shortcut is deleted rather than kept with a covers.source_url column. Store surface is a second method ReplaceSeriesCover writing cover as well as cover_address (a force bool rejected: three callers would pass false for ever). No byte deletion here — reclamation for both causes goes to #125. Fetch reuses fetchCoverBytes, so kagane and comix covers keep the sidecar and a missing sidecar merely ages the mark; the Lanes page already states the sidecar fact, so no new notification. Unconfirmed, no --danger, never --ember: a Cover that disagrees with the Site is the confusing state, not the change.

  • Data-correction actions: Latest Chapter and series_url repair — the motivating case was not a defect: #79 settled that the chapter-list maximum is Latest Chapter and forbids capping it, so "fixes the class of #79" is struck and the surviving class is a Reader-raised value on a Series the Poll cannot read (#119's Forced Poll covers every readable one). A hand-set value is therefore not authoritative — no pin, no floor: it is overwritten by checkOne and by any Reader's PUT (store.go:864-867 last-write-wins, reportLatestChapter sends the site-read number up or down on every series-page visit), and being overwritten by a page read is correct because the Poll is the oracle. One numeric input, label derived as Chapter <num> (a free-text label can disagree with the number the ember compares). series_url repair is owner-typed only, validated by the existing gate renamed to latest.FetchableSeriesURL — Reader-PUT healing rejected because series is a shared row and the gate stops SSRF, not mis-pointing; a Site-wide host change stays a migration. New latest_corrected_at (migration 0015) means "the current value is a human's", zeroed by SetLatestChapter and by the Upsert only when the number actually changes — deliberately not an audit trail. A contradicting correction calls ClearSightingAttribution, never RecordSightingOutcome (a mark needs 20 confirming Polls that this class will never get). Detail page only, unconfirmed, silent to Readers; pushes onto #127 that a correction must not clear a failure counter, and nothing onto #116/#118.

  • Prototype: the Cinder admin surface — the artifact is the Claude Design project BookmarkManager Web UI (969ac210-fe02-4c01-ae1b-9a271dcc779a), files admin.css + admin-{overview,lanes,readers,series,series-detail}.html; branch prototype/admin-surface-122 is superseded exploration, not the thing to port. One new token, --measure-wide: 1080px — admin only, --measure: 760px unchanged for reading pages. Row shape is aligned borderless columns: one CSS grid per table, hairline header and row rules, no vertical rules or cards, gutters as cell padding, tabular mono figures in mixed case; the Series list is the subgrid two-line form (title line over fact line) separated by banding (--hover) rather than hairlines, Lanes and the per-Site table single-line. Actions are right-aligned 12px ghosts: Check now unconfirmed with its requested <age> ago marker on the title line, destruction via .ghost.danger opening an in-place .confirm-row on --danger-wash. No ember anywhere on admin; --patina is the accent, --danger for lane trouble and destruction, a deliberate pause stays patina. Also fixed: nav row of four display-face tabs, .sec heads led by a patina tick instead of a rule, verdict line in the mono data face, stats as an auto-fit grid with zeros unlinked, the landing per-Site table carrying library shape only (#117's five outcome sums stay on Lanes), and the phone breakpoints (Lanes ≤1019px, everything else ≤899px), which retires the phone entry from Not yet specified. Amends #118: the eight hygiene filters are a <select> with counts in the option labels, not a chip row — one-at-a-time and URL-addressable as specified, only the control changed.

  • Series-level finished state, and the fate of the finished Lifecycle bucket — finished moves from the Bookmark to the Series and the Reader vote is deleted, recorded as ADR-0012 (docs/adr/0012-finished-belongs-to-the-series.md). New column series.finished_at bigint NOT NULL DEFAULT 0 (migration 0016, epoch-ms, zero means never, owner-written only — no adapter, Reader or Poll may set it), so both Lane queries drop HAVING COUNT(*) FILTER (WHERE b.status <> 'finished') > 0: DueForLatestCheck gains WHERE (s.finished_at = 0 OR s.force_poll_at > s.latest_checked_at) and EligibleSeriesCount gains s.finished_at = 0 with deliberately no force clause, so one press cannot speed up the Lane's pace. That kills the aggregated-per-Reader-votes bug outright. The finished Lifecycle bucket is removed (Lifecycle bucket is now two states; 0016 flips surviving rows to archived before seeding the column, order load-bearing), and Readers instead get a read-only derived finished bool on the wire — purely a label, discarded inbound by Upsert's explicit column list exactly as cover is. A Forced Poll overrides a finish for one pass and never clears it; un-finishing is an explicit owner action. Control lives on the Series detail page only, confirm-gated to finish and instant to un-finish, --patina not --danger. finished becomes the tenth filter name (ordered last, informational), and stale/unchecked/no-cover/no-chapter each gain AND s.finished_at = 0 so a finished Series never ages into a hygiene figure. A Site's own "completed" marker may never write the column — it may only hint, split out as #129 and #130.

  • Research: which Sites publish a machine-readable completion marker — all six publish one, and a hint keyed on the completed value has no false-positive path on any Site (measured 2026-08-19; note on branch research/completion-markers, file docs/research/completion-markers.md). Four Sites carry the value in a payload the adapter already fetches — asurascans in its astro-island status:[0,"…"] props, comix in the initial-data detail blob, kagane in the API body's publication_status + upload_status, lightnovelworld in JSON-LD creativeWorkStatus — so extraction is cheap; demonicscans (Status <li> pair) and novelfull (/status/<value> link) need one selector each. Vocabularies split three ways: kagane is the only Site separating the work's status from the translation's (proven divergent on one series), asurascans' dropped/hiatus and kagane's upload_status are scanlation-editorial state rather than completion, and demonicscans + novelfull are binary (Ongoing/Completed), so a stalled series there reads Ongoing for ever. The residual risk is therefore false negatives only, which inverts #130's containment problem.

  • Orphan Series: is removal an owner action, and what happens to the Cover bytes — yes, one at a time, orphans only: Remove on the /admin/series row and on the detail page, rendered solely at reader_count = 0, with bookmarks_series_fk (no cascade) as the entire guard — a plain DELETE FROM series whose FK violation means "a Reader bookmarked it again"; no bulk sweep, and the delete is no one-way door since Upsert recreates the row. Amends #122: Check now leaves the orphan row (a Forced Poll never overrides the bookmarks join, so the request would age for ever and fire #119's stuck-Lane signal), and the confirm copy stops promising byte deletion. Cover reclamation is one guarded helper called at both causes (removal and #120's replace): delete the covers row only WHERE NOT EXISTS a Series pointing at it — no sweep, no schedule, no retry surface. Order inverted from the obvious one: unlink first, covers row last, because the row is the handle — an interrupted reclamation is then one SQL query over unreferenced covers rows instead of a filesystem walk, and #120's "a non-blank cover_address is unrepairable" is superseded by its own forced replace path (one Check now re-links the missing file). Pushes ON DELETE CASCADE for any per-Series table onto #127.

  • Per-Series Poll outcome state and the failing filter
    — one table, poll_failures (migration 0017), whose row existence is the state: a row means the
    last Poll that learned anything about this Series failed, and a correct read deletes it, so
    there is no '' success sentinel, no CHECK, and no counter. Keyed (site, series_id)
    REFERENCES series ON DELETE CASCADE per #125, series untouched. 3NF was checked and does not
    decide
    columns-vs-table (same key, 1:1, both BCNF); the table won on the sentinel, the narrower
    series, and a simpler guarded write — ON CONFLICT DO UPDATE … WHERE outcome <> excluded.outcome
    keeps failing_since across a change of word, so the same failure repeating and a healthy Poll both
    write nothing. Only Series-evidence writes: no_chapter, unfetchable, not_found, errors;
    a refused or unreachable Poll issues no statement at all — writing would mark a whole library
    as failing when one Site refused, deleting would claim recovery when nothing was read and reset the
    age that makes a three-month failure findable. not_found splits out of errors (4xx other than
    403), making #117's taxonomy six words. Two filter names: failing (ninth) = row exists AND latest_chapter_num IS NOT NULL AND failing_since older than ownerWindow (12h, no new constant) —
    the latest_chapter_num test is what keeps it disjoint from #118's no-chapter, and per #124 there
    is no finished_at guard; unverified (eleventh) = latest_raised_by IS NOT NULL plus the
    failing test, the pair rejected as a stored column because it would be derived data in two tables
    with five writers. Under-12h and over-12h rows render identically (<word> · failing <age> in
    --danger); the threshold decides list membership only. #117's and #118's promise that the
    per-Site outcome counts would link to ?filter=failing is withdrawn
    — two counts now have no
    per-Series record and the other four count attempts-in-12h rather than currently-failing Series, so
    each Lanes-page Site row gets one separate navigation link instead. Non-goal: DueForLatestCheck
    does not join the table, so a failing Series is polled at the same pace.

  • Completion-marker hint: storage, presentation, and false-positive containment #130
    — the containment machinery the title asked for is not built: #129 removed the false-positive
    path (read only the completed value and no Site can lie about completion), so there is no
    N-consecutive-observation gate, no expiry and no confirmation counter, and the residual error is a
    false negative we accept. Storage is one column, series.site_completed_at (epoch-ms of the
    read that saw the Site's completed value, zero means the last successful read did not) — decay comes
    free, since every successful read rewrites it; a normalised status word was rejected because the
    vocabularies are not comparable (demonic and novelfull binary, only kagane splits publication_status
    from upload_status). One per-Site predicate reads the completed value only — asura completed,
    demonic Completed, comix finished, kagane publication_status only, novelfull Completed,
    lnw CompletedActionStatus; a failed extraction means not-completed, and per #127 a refused or
    unreachable Poll makes no statement, keeping the previous value. All six adapters read it,
    including demonic's and novelfull's new selectors, because on those two Sites Completed is the only
    signal that exists and a broken selector degrades to a missing hint. The write is its own statement in
    checkOne after the read (#127 killed the shared write) and fires only on a transition —
    site_completed_at joins the due-query projection, so the ordinary ongoing Poll writes nothing.
    Presentation is the twelfth filter name site-completed plus one landing stats figure linking to
    it (zero unlinked per #118) plus one line on the detail page; no list-row field (#122 gives a row
    two lines). The Finish control is unchanged and still confirm-gated — the hint is text beside it,
    never a shortened path, or the Site would decide the Lifecycle. No dismissal column. The filter
    carries AND finished_at = 0 and the detail page hides the hint on a finished Series, whose value
    #124 freezes by removing it from the Poll query.

  • Latest Chapter provenance: what records that the number moved
    — nothing does, and nothing will: no history table, no attribution column, no migration 0018.
    The actor behind the present number is derived from state already decided, in three classes:
    Correction (latest_corrected_at <> 0), Sighting (latest_raised_by IS NOT NULL), machine read
    (neither, with latest_checked_at > 0). Acquisition does not survive as a distinct actor —
    acquire.go:125 stamps latest_checked_at exactly as poller.go:428 does, and the only
    actionable case is already the unchecked filter. This promotes #121's latest_corrected_at
    from convenience to load-bearing
    (the sole evidence a Correction happened, kept honest by its
    zeroing rules), so it must not be dropped. Presentation is one line on the Series detail page
    naming the actor class: no list-row field, no thirteenth filter name, no landing figure. The
    Cover half is closed by construction (#120's SHA-256 address, #125's reclamation) and a
    superseded Cover is deliberately unrecoverable; #118's "went backwards" filter stays rejected
    on its original ground, since nothing stores the previous number. A history table was designed
    before being dropped (one row per real change, actor class only, newest-20-per-Series pruned on
    insert, migration 0018) and died on three costs: a per-Series timestamped table is one column
    away from the per-Reader reading log the map rules out of scope; it would duplicate "the
    current value is a human's" with a second writer, which #127 rejected for this exact reason; and
    no owner question needs the past, because the Poll is the oracle and sighting_disagreements
    already carries distrust. Glossary gains Correction; Movement deliberately not added.

  • Notification outside the page: is the dashboard pull-only? — not pull-only: four conditions push one Discord embed each, and the rule that selects them is the decision — silent on every Reader surface, wrong, repairable only by the owner, and holding past ownerWindow (12h). stall (#117's test amended with AND refused = 0, because the refusals >= 2 break at poller.go:301-306 is not an early return and so read as a stall, double-firing on a newly-gated Site), no-browser-route (a Site with no browser route refusing >12h — the comix 2026-08-12 event, repaired by a deploy; a browser Site refusing sends nothing, per ADR-0010), sidecar-down (>12h — invisible to the stall test, since poller.go:234/245 return skip values, so "asleep an hour" and "gone a week" look alike), adapter-broken (over half of a Site's Series holding a #127 no_chapter row >12h — a share so a 4-Series and a 200-Series Site both fire). Never: a browser Site's refusal, a pause, one failing Series, an ageing forced Poll, a gated cover host. Fires in the deferred poll_passes insert — the only point every return path crosses — and needs no timer, since Poller.lane already wakes at least hourly (the ticket's "no clock" premise was wrong). Suppression is new owner_notices(kind, site, notified_at), one row per episode cleared when the condition lifts; site is the empty string for sidecar-down so three browser Lanes send one message, the row's presence being the lock — which is why it is a table, not columns on poll_lanes. Thresholds stay derived from poll_passes and failing_since; the only new durable fact is that the owner was told. DISCORD_WEBHOOK_URL, unset means off (as BROWSER_WS_URL), never logged, and it touches no part of the OAuth login path; a failed POST logs and leaves notified_at unset, so the next pass retries — no queue. Presentation is an embed, colour 13589581 (dark --danger), title carrying the PUBLIC_BASE_URL deep link, one-sentence description, Discord timestamp, machine word in the footer, no field grid; --ember stays forbidden. Test seam is Poller.Notifier, nil meaning off. Amends #115 and #122: the landing verdict line must read the same four conditions. Rejected transports: Telegram (a second identity), ntfy.sh public (topics are public by design, 250/day), self-hosted ntfy/Gotify (the infrastructure this refuses). CONTEXT.md gains Stall; no ADR (reversible by deletion).

Not yet specified

Empty — the way to the destination is charted; nothing in scope remains unspecified.

Out of scope

  • Multiple admins, a role column, or per-user admin grants — the single-owner model is the
    destination's boundary.

  • Inspecting an individual Reader's library or reading progress. Ruled out by the privacy
    boundary; only anonymous counts cross into admin views.

  • Metrics, charts, or a timeseries store. A swapless 1974 MiB VPS running Postgres argues
    against it, and no decision on this map needs a graph.

  • A monitor outside the backend (an external prober against /healthz, Uptime Kuma, healthchecks.io). A push cannot report its own process's death, so the honest coverage lives outside — but it is another service to run, watch and update, and it watches a fault that is already visible from the Reader surfaces: the web UI dies visibly, and the userscript degrades to its cache with ⟳ N pending in the panel (manga-bookmark.user.js:1313-1314), losing no Progress. Downtime therefore costs a delay and is found within one reading session. Ruled out while resolving #132.

  • Bulk rewrite of series_url across a whole Site. A host change (asuracomic -> asurascans)
    invalidates every row of a Site at once, and Data-correction actions: Latest Chapter and series_url repair
    already ruled that case a SQL migration rather than a dashboard control — the dashboard's
    library-wide job is finding broken rows, not batch-repairing them. Was a fog line; it is a
    scope boundary, not an undiscovered decision, so it never graduates.

## Destination A **spec** for a proper owner admin dashboard — locked decisions plus enough written detail that later sessions can implement it without re-deciding anything. The dashboard's job, in priority order: **observability** (is the poller healthy, what happened while I was away), **intervention** (act on one Series or Reader), **library-wide hygiene** (find broken rows across all Readers). Not built here; charted and specified here. ## Notes Domain: the self-hosted Go backend for BookmarkManager (manga + novel read-progress tracking), its owner-only web surface, and the Poll Lanes that keep Latest Chapter current. Skills every session on this map should consult: `/grilling` and `/domain-modeling` by default; `/prototype` for the visual-surface ticket; `/research` for research tickets. Standing decisions already settled while charting (do not relitigate): - Single owner. `requireOwner` (`backend/internal/web/admin.go`) stays the whole access model — no roles, no second admin. - The admin surface is an **extension of Cinder**, negotiated in the Claude Design project `BookmarkManager Web UI`, not a second visual language. One narrow 760px prose column cannot carry an operations view, but cards/corners/shadows and a rival token set stay out. - Privacy boundary: **Series-level facts plus an anonymous Reader count**. The owner never sees which Reader reads what, or anyone's progress list. `DueForLatestCheck` (`store.go`) already computes a `readerCount` this way — same shape. - Intervention reaches the poller **through the database**, never by commanding it. The admin page reads Lane state **from the database too** (#117 persists it and deletes `Poller.laneStates`, `LaneStatus()` and `LaneReporter`), so the whole surface stays testable with no poller running — a test inserts a row rather than constructing a fake. A forced action is a flag the Lane notices on its next pass, and the UI promises exactly that rather than pretending to be synchronous. - The page leads with **one summary line** aggregating Lane state and Series staleness. Being able to tell at a glance whether to keep reading is most of what "proper dashboard" means. Facts established while charting (2026-08-17, from the code): - Today `/admin` is two blocks: `templates/lanes.html` (per-Site Lane status, htmx 30s refresh) and `templates/readers.html` (roster, revoke sessions, clear marks). - **No poll history is persisted anywhere.** Lane state lives in `Poller.laneStates` and is lost on restart; the only per-poll DB writes are `MarkLatestChecked`, `SetLatestChapter`, `RecordSighting*`, `SetSeriesCover`. There is no per-Series failure counter column. - **No generic all-Series query exists.** `store.List`/`Get` are per-`readerID` only; `DueForLatestCheck` is the sole cross-reader join. A Series list view needs a new read model. - `SetSeriesCover` only fills an *empty* `cover_address`, so a cover refetch needs a force path in the store, not merely a new caller. - `series` carries `latest_sighted_at` and `latest_raised_by` (FK to `readers`) — per-Reader attribution already sits on a shared row. ## Tickets <!-- canonical child list; a ticket's own body carries its `Blocked by:` line --> - [x] #115 Admin dashboard information architecture and route map - [x] #116 Cross-Series read model and the privacy boundary - [x] #117 Poll history: persist it, or stay a live-now view - [x] #118 Hygiene filters for the Series view - [x] #119 Poller command seam: force-poll and pause-lane through the database - [x] #120 Per-Series cover refetch and the store's force path - [x] #121 Data-correction actions: Latest Chapter and series_url repair - [x] #122 Prototype: the Cinder admin surface - [x] #123 Research: how far htmx and the existing filter.js carry a filterable Series list - [x] #124 Series-level finished state, and the fate of the finished Lifecycle bucket - [x] #125 Orphan Series: is removal an owner action, and what happens to the Cover bytes - [x] #127 Per-Series Poll outcome state and the failing filter - [x] #129 Research: which Sites publish a machine-readable completion marker - [x] #130 Completion-marker hint: storage, presentation, and false-positive containment - [x] #131 Latest Chapter provenance: what records that the number moved - [x] #132 Notification outside the page: is the dashboard pull-only? ## Decisions so far <!-- one line per closed ticket: gist plus link; detail lives in the ticket --> - [Research: how far htmx and the existing filter.js carry a filterable Series list](https://gitea.violetcrown.my.id/sulthan/mangaBookmark/issues/123) — nothing new needed: vendored htmx 2.0.4 covers URL-addressable filters (`hx-push-url`), debounced search, load-more/numbered paging, per-row swaps and confirm-gating. But `static/filter.js` is a client-side title filter, not URL-addressable, and is not loaded on `/admin` — so the Series list filters server-side. Note on branch `research/htmx-series-list`. - [Admin dashboard information architecture and route map](https://gitea.violetcrown.my.id/sulthan/mangaBookmark/issues/115) — four pages behind one nav row (`/admin` landing, `/admin/lanes`, `/admin/readers`, `/admin/series`, `/admin/series/{key}` keyed `site:series_id`), all joining `adminRoutes()`. Split rule: **landing shows library shape from the database, the Lanes page shows poller liveness from memory** — no figure on both. Lanes keeps the only timer (`defaultRest = time.Hour`, so the new pages have nothing to re-fetch); new actions post to `/admin/series/{key}/<verb>` and answer with the swapped block. Landing leads with `<verdict> · N series waiting · M unchecked over 24h`, then a stats block whose hygiene counts link into `/admin/series?filter=…`. Pushes a per-Site 24h failure count onto #117 and a grouped per-Site aggregate onto #116. - [Cross-Series read model and the privacy boundary](https://gitea.violetcrown.my.id/sulthan/mangaBookmark/issues/116) — two store methods (`AdminSeries(SeriesFilter) ([]SeriesRow, total, error)` for rows, `SeriesStats(staleBefore)` for the landing aggregate) over a **new `SeriesRow` type that has no field for `latest_raised_by`** — the privacy boundary is the projection, not a template, and attribution surfaces only as an anonymous `SightingRaised` bool computed in SQL. **LEFT JOIN `bookmarks`**, so orphan Series (`reader_count = 0`) become visible instead of hidden as the Lane hides them; Reader count is a plain `COUNT`, which knowingly disagrees with the Lane's `status <> 'finished'` test until #124 removes that bucket. Seven URL-addressable filter names (`no-cover`, `unchecked`, `stale`, `unpollable`, `sighting-raised`, `orphan`, none) as compile-time predicate constants plus `?site=`/`?kind=` params; `unpollable` means `series_url = ''` only. 50 rows a page with `COUNT(*) OVER ()` as the total and an empty-page re-read at page 1. **Stale is 12h, superseding the 24h figure in #115.** Index `series (latest_checked_at)` in migration 0012, stated as a judgement to re-time. - [Poller command seam: force-poll and pause-lane through the database](https://gitea.violetcrown.my.id/sulthan/mangaBookmark/issues/119) — two pieces of database state, no command channel. **Forced Poll** is `series.force_poll_at` (migration 0013), pending derived as `force_poll_at > latest_checked_at` and self-clearing on `checkOne`'s stamp-before-fetch, with no expiry — an ageing request is the evidence a Lane is stuck. It overrides the waiting rules (rest cutoff, Sighting deferral, finished-only bucket) and wakes a sleeping Chrome, never the refusal backoff, the gap, an empty `series_url` or the bookmarks join. **Paused Lane** is a `poll_lanes(site, paused_until)` row with a *mandatory* expiry (1h/6h/24h), read at the top of `runLanePass`, surviving restarts; no global runtime pause — `LATEST_CHAPTER_POLL_ENABLED` plus a redeploy stays that. Acquisition is untouched by a pause. UI: *Check now* on both the list row and the detail page from one fragment, unconfirmed, `check requested <age> ago`, no ETA; the Lanes page reads pause **from the database**, since memory is empty after a restart while a pause is not. Pushes `force_poll_at` onto #116's projection, two controls onto #122, and the Sighting-remedy wording onto #121. - [Poll history: persist it, or stay a live-now view](https://gitea.violetcrown.my.id/sulthan/mangaBookmark/issues/117) — **one row per Lane Pass**, append-only, ~120 rows/day: `poll_passes` (migration 0014) carrying `due`/`checked`/`gap_ms`/`clamped`, a nine-value **`skip`** enum (one per `runLanePass` return path, so `due > 0 AND checked = 0 AND skip = ''` is the only true stall), and five named outcome counts (`refused`/`unreachable`/`no_chapter`/`unfetchable`/`errors`) mapped from the classification `readSeriesPage` already makes; success is derived, never stored, and a zero renders as *none observed*. **`Poller.laneStates`, `LaneStatus()`, `LaneReporter` and `Poller.refuseUntil` are deleted** — refusal becomes durable `poll_lanes.refuse_until` (the Site's mood outlives our process) while `browserDownAt` stays in memory (our own reach does not), and sidecar reachability is derived from `unreachable` inside `latest.RefuseBackoff`. Retention is **delete-on-insert, 14 days**, pruned by the Lane goroutine — no `time.Ticker` enters a backend that has none. One `ownerWindow = 12 * time.Hour` feeds both the staleness cutoff and the outcome window, retiring #116's `staleAfter`. **#115's split rule is void** (its premise was the deleted memory) and becomes *landing aggregates over Sites, Lanes page shows per-Lane detail*. Amends #119: `poll_lanes` becomes the per-Site state row, so `ResumeLane` zeroes `paused_until` instead of deleting. Audit trail **not** subsumed; the per-Series hole is #127. - [Hygiene filters for the Series view](https://gitea.violetcrown.my.id/sulthan/mangaBookmark/issues/118) — the filter vocabulary closes at **eight**: #116's seven plus **`no-chapter`** (`latest_chapter_num IS NULL AND latest_checked_at > 0 AND series_url <> ''`), a Series the poller has attempted and never once read a chapter from — durable today, no column, no write on the poll path, and not subsumed by #127's `failing` (never worked vs worked for a year and broke on Tuesday). **A "Latest Chapter went backwards" filter is rejected**: nothing stores the previous value, and a downward write is the *correction* (`poller_test.go:481-497` asserts 400 -> 296), so it would flag the Series that just healed while a stable wrong number never fires it at all; `sighting-raised` stays the only suspicion lens and #121 the repair. Presentation: one display label per filter, ordered permanent-and-fixable first; **every figure in the landing stats block links to what it counts** (total, kind split, Site label, roster — not just hygiene counts) **except a zero, which prints the digit unlinked** rather than opening an empty list; one filter at a time with `?site=`/`?kind=` stacking, heading `<N> series · <label>`, and an empty state naming the filter it is empty for. **No aggregate problem count** — the classes overlap, so a sum over-reports. #117's five per-Site outcome counts stay **unlinked** (the pass row holds counts, never identities) until #127, then all five point at `?site=…&filter=failing`. Row field layout and density stay #122's call. - [Per-Series cover refetch and the store's force path](https://gitea.violetcrown.my.id/sulthan/mangaBookmark/issues/120) — the owner replaces a Cover by asking for a **Forced Poll**: no second column, no dedicated control, no synchronous fetch, and `checkOne` swaps `fillBlankCover` for a replace when the pass was forced (an unforced Poll still only fills a blank). New cover writes are addressed by the **SHA-256 of the bytes** while legacy URL-hashed rows are left untouched — legal with no migration because `coverAddressRe` is the only contract on the value, and a rehash could not be one anyway (migrations are SQL-only). That makes a re-art behind an unchanged URL visible and makes identical art an honest no-op the page can report, instead of the double silent no-op `os.Link`+`ON CONFLICT DO NOTHING` produce today; `CoverAddress`'s name-it-before-the-bytes property has no production caller, and `prefetchCover`'s `GetCover` shortcut is deleted rather than kept with a `covers.source_url` column. Store surface is a second method **ReplaceSeriesCover** writing `cover` as well as `cover_address` (a `force bool` rejected: three callers would pass `false` for ever). **No byte deletion here** — reclamation for both causes goes to #125. Fetch reuses `fetchCoverBytes`, so kagane and comix covers keep the sidecar and a missing sidecar merely ages the mark; the Lanes page already states the sidecar fact, so no new notification. Unconfirmed, no `--danger`, never `--ember`: a Cover that disagrees with the Site is the confusing state, not the change. - [Data-correction actions: Latest Chapter and series_url repair](https://gitea.violetcrown.my.id/sulthan/mangaBookmark/issues/121) — **the motivating case was not a defect**: #79 settled that the chapter-list maximum *is* Latest Chapter and forbids capping it, so "fixes the class of #79" is struck and the surviving class is a Reader-raised value on a Series the Poll **cannot** read (#119's Forced Poll covers every readable one). A hand-set value is therefore **not authoritative — no pin, no floor**: it is overwritten by `checkOne` *and* by any Reader's PUT (`store.go:864-867` last-write-wins, `reportLatestChapter` sends the site-read number up or down on every series-page visit), and being overwritten by a page read is correct because the Poll is the oracle. **One numeric input**, label derived as `Chapter <num>` (a free-text label can disagree with the number the ember compares). `series_url` repair is **owner-typed only**, validated by the existing gate renamed to **`latest.FetchableSeriesURL`** — Reader-PUT healing rejected because `series` is a shared row and the gate stops SSRF, not mis-pointing; a Site-wide host change stays a migration. New `latest_corrected_at` (migration 0015) means **"the current value is a human's"**, zeroed by `SetLatestChapter` and by the Upsert *only when the number actually changes* — deliberately **not** an audit trail. A contradicting correction calls `ClearSightingAttribution`, never `RecordSightingOutcome` (a mark needs 20 confirming Polls that this class will never get). Detail page only, unconfirmed, silent to Readers; pushes onto #127 that a correction must not clear a failure counter, and nothing onto #116/#118. - [Prototype: the Cinder admin surface](https://gitea.violetcrown.my.id/sulthan/mangaBookmark/issues/122) — the artifact is the Claude Design project **BookmarkManager Web UI** (`969ac210-fe02-4c01-ae1b-9a271dcc779a`), files `admin.css` + `admin-{overview,lanes,readers,series,series-detail}.html`; branch `prototype/admin-surface-122` is superseded exploration, not the thing to port. **One new token, `--measure-wide: 1080px`** — admin only, `--measure: 760px` unchanged for reading pages. Row shape is **aligned borderless columns**: one CSS grid per table, hairline header and row rules, no vertical rules or cards, gutters as cell padding, tabular mono figures in mixed case; the Series list is the **subgrid two-line** form (title line over fact line) separated by **banding** (`--hover`) rather than hairlines, Lanes and the per-Site table single-line. Actions are right-aligned 12px ghosts: *Check now* unconfirmed with its `requested <age> ago` marker on the title line, destruction via `.ghost.danger` opening an in-place `.confirm-row` on `--danger-wash`. **No ember anywhere on admin**; `--patina` is the accent, `--danger` for lane trouble and destruction, a deliberate pause stays patina. Also fixed: nav row of four display-face tabs, `.sec` heads led by a patina tick instead of a rule, verdict line in the mono data face, stats as an auto-fit grid with zeros unlinked, the landing per-Site table carrying **library shape only** (#117's five outcome sums stay on Lanes), and the phone breakpoints (Lanes ≤1019px, everything else ≤899px), which retires the phone entry from Not yet specified. **Amends #118**: the eight hygiene filters are a `<select>` with counts in the option labels, not a chip row — one-at-a-time and URL-addressable as specified, only the control changed. - [Series-level finished state, and the fate of the finished Lifecycle bucket](https://gitea.violetcrown.my.id/sulthan/mangaBookmark/issues/124) — **finished moves from the Bookmark to the Series and the Reader vote is deleted**, recorded as **ADR-0012** (`docs/adr/0012-finished-belongs-to-the-series.md`). New column `series.finished_at bigint NOT NULL DEFAULT 0` (migration 0016, epoch-ms, zero means never, **owner-written only** — no adapter, Reader or Poll may set it), so both Lane queries drop `HAVING COUNT(*) FILTER (WHERE b.status <> 'finished') > 0`: `DueForLatestCheck` gains `WHERE (s.finished_at = 0 OR s.force_poll_at > s.latest_checked_at)` and `EligibleSeriesCount` gains `s.finished_at = 0` with deliberately **no** force clause, so one press cannot speed up the Lane's pace. That kills the aggregated-per-Reader-votes bug outright. The `finished` Lifecycle bucket is **removed** (`Lifecycle bucket` is now two states; 0016 flips surviving rows to `archived` *before* seeding the column, order load-bearing), and Readers instead get a read-only derived `finished bool` on the wire — purely a label, discarded inbound by `Upsert`'s explicit column list exactly as `cover` is. A Forced Poll **overrides** a finish for one pass and never clears it; un-finishing is an explicit owner action. Control lives on the Series **detail page only**, confirm-gated to finish and instant to un-finish, `--patina` not `--danger`. `finished` becomes the **tenth** filter name (ordered last, informational), and `stale`/`unchecked`/`no-cover`/`no-chapter` each gain `AND s.finished_at = 0` so a finished Series never ages into a hygiene figure. A Site's own "completed" marker may **never** write the column — it may only hint, split out as #129 and #130. - [Research: which Sites publish a machine-readable completion marker](https://gitea.violetcrown.my.id/sulthan/mangaBookmark/issues/129) — **all six publish one, and a hint keyed on the completed value has no false-positive path on any Site** (measured 2026-08-19; note on branch `research/completion-markers`, file `docs/research/completion-markers.md`). Four Sites carry the value in a payload the adapter *already* fetches — asurascans in its astro-island `status:[0,"…"]` props, comix in the `initial-data` detail blob, kagane in the API body's `publication_status` + `upload_status`, lightnovelworld in JSON-LD `creativeWorkStatus` — so extraction is cheap; demonicscans (`Status` `<li>` pair) and novelfull (`/status/<value>` link) need one selector each. Vocabularies split three ways: kagane is the only Site separating the **work's** status from the **translation's** (proven divergent on one series), asurascans' `dropped`/`hiatus` and kagane's `upload_status` are scanlation-editorial state rather than completion, and demonicscans + novelfull are **binary** (Ongoing/Completed), so a stalled series there reads Ongoing for ever. The residual risk is therefore **false negatives only**, which inverts #130's containment problem. - [Orphan Series: is removal an owner action, and what happens to the Cover bytes](https://gitea.violetcrown.my.id/sulthan/mangaBookmark/issues/125) — **yes, one at a time, orphans only**: *Remove* on the `/admin/series` row and on the detail page, rendered solely at `reader_count = 0`, with `bookmarks_series_fk` (no cascade) as the entire guard — a plain `DELETE FROM series` whose FK violation means "a Reader bookmarked it again"; no bulk sweep, and the delete is no one-way door since `Upsert` recreates the row. **Amends #122**: *Check now* leaves the orphan row (a Forced Poll never overrides the `bookmarks` join, so the request would age for ever and fire #119's stuck-Lane signal), and the confirm copy stops promising byte deletion. Cover reclamation is **one guarded helper** called at both causes (removal and #120's replace): delete the `covers` row only `WHERE NOT EXISTS` a Series pointing at it — no sweep, no schedule, no retry surface. **Order inverted from the obvious one: unlink first, `covers` row last**, because the row is the handle — an interrupted reclamation is then one SQL query over unreferenced `covers` rows instead of a filesystem walk, and #120's "a non-blank `cover_address` is unrepairable" is superseded by its own forced replace path (one *Check now* re-links the missing file). Pushes `ON DELETE CASCADE` for any per-Series table onto #127. - [Per-Series Poll outcome state and the failing filter](https://gitea.violetcrown.my.id/sulthan/mangaBookmark/issues/127) — one table, `poll_failures` (migration 0017), whose **row existence is the state**: a row means the last Poll that learned anything about this Series failed, and a correct read **deletes** it, so there is no `''` success sentinel, no `CHECK`, and no counter. Keyed `(site, series_id)` `REFERENCES series ON DELETE CASCADE` per #125, `series` untouched. 3NF was checked and **does not decide** columns-vs-table (same key, 1:1, both BCNF); the table won on the sentinel, the narrower `series`, and a simpler guarded write — `ON CONFLICT DO UPDATE … WHERE outcome <> excluded.outcome` keeps `failing_since` across a change of word, so the same failure repeating and a healthy Poll both write nothing. **Only Series-evidence writes**: `no_chapter`, `unfetchable`, `not_found`, `errors`; a `refused` or `unreachable` Poll issues **no statement at all** — writing would mark a whole library as failing when one Site refused, deleting would claim recovery when nothing was read and reset the age that makes a three-month failure findable. **`not_found` splits out of `errors`** (4xx other than 403), making #117's taxonomy six words. Two filter names: **`failing`** (ninth) = row exists `AND latest_chapter_num IS NOT NULL AND failing_since` older than `ownerWindow` (12h, no new constant) — the `latest_chapter_num` test is what keeps it disjoint from #118's `no-chapter`, and per #124 there is no `finished_at` guard; **`unverified`** (eleventh) = `latest_raised_by IS NOT NULL` plus the `failing` test, the pair rejected as a stored column because it would be derived data in two tables with five writers. Under-12h and over-12h rows **render identically** (`<word> · failing <age>` in `--danger`); the threshold decides list membership only. **#117's and #118's promise that the per-Site outcome counts would link to `?filter=failing` is withdrawn** — two counts now have no per-Series record and the other four count attempts-in-12h rather than currently-failing Series, so each Lanes-page Site row gets one separate navigation link instead. Non-goal: `DueForLatestCheck` does not join the table, so a failing Series is polled at the same pace. - Completion-marker hint: storage, presentation, and false-positive containment https://gitea.violetcrown.my.id/sulthan/mangaBookmark/issues/130 — **the containment machinery the title asked for is not built**: #129 removed the false-positive path (read only the completed value and no Site can lie about completion), so there is no N-consecutive-observation gate, no expiry and no confirmation counter, and the residual error is a *false negative* we accept. Storage is **one column, `series.site_completed_at`** (epoch-ms of the read that saw the Site's completed value, zero means the last successful read did not) — decay comes free, since every successful read rewrites it; a normalised status word was rejected because the vocabularies are not comparable (demonic and novelfull binary, only kagane splits `publication_status` from `upload_status`). One per-Site predicate reads **the completed value only** — asura `completed`, demonic `Completed`, comix `finished`, kagane `publication_status` **only**, novelfull `Completed`, lnw `CompletedActionStatus`; a failed extraction means not-completed, and per #127 a refused or unreachable Poll makes **no statement**, keeping the previous value. **All six adapters** read it, including demonic's and novelfull's new selectors, because on those two Sites `Completed` is the only signal that exists and a broken selector degrades to a missing hint. The write is its own statement in `checkOne` after the read (#127 killed the shared write) and fires **only on a transition** — `site_completed_at` joins the due-query projection, so the ordinary ongoing Poll writes nothing. Presentation is the **twelfth filter name `site-completed`** plus one landing stats figure linking to it (zero unlinked per #118) plus one line on the detail page; **no list-row field** (#122 gives a row two lines). The Finish control is **unchanged and still confirm-gated** — the hint is text beside it, never a shortened path, or the Site would decide the Lifecycle. **No dismissal column.** The filter carries `AND finished_at = 0` and the detail page hides the hint on a finished Series, whose value #124 freezes by removing it from the Poll query. - [Latest Chapter provenance: what records that the number moved](https://gitea.violetcrown.my.id/sulthan/mangaBookmark/issues/131) — **nothing does, and nothing will**: no history table, no attribution column, no migration 0018. The actor behind the present number is *derived* from state already decided, in **three classes**: Correction (`latest_corrected_at <> 0`), Sighting (`latest_raised_by IS NOT NULL`), machine read (neither, with `latest_checked_at > 0`). **Acquisition does not survive as a distinct actor** — `acquire.go:125` stamps `latest_checked_at` exactly as `poller.go:428` does, and the only actionable case is already the `unchecked` filter. This **promotes #121's `latest_corrected_at` from convenience to load-bearing** (the sole evidence a Correction happened, kept honest by its zeroing rules), so it must not be dropped. Presentation is **one line on the Series detail page** naming the actor class: no list-row field, no thirteenth filter name, no landing figure. The Cover half is closed by construction (#120's SHA-256 address, #125's reclamation) and a superseded Cover is **deliberately unrecoverable**; #118's "went backwards" filter stays rejected on its original ground, since nothing stores the previous number. A history table *was* designed before being dropped (one row per real change, actor class only, newest-20-per-Series pruned on insert, migration 0018) and died on three costs: a per-Series timestamped table is one column away from the per-Reader reading log the map rules **out of scope**; it would duplicate "the current value is a human's" with a second writer, which #127 rejected for this exact reason; and no owner question needs the past, because the Poll is the oracle and `sighting_disagreements` already carries distrust. Glossary gains **Correction**; **Movement** deliberately not added. - [Notification outside the page: is the dashboard pull-only?](https://gitea.violetcrown.my.id/sulthan/mangaBookmark/issues/132) — **not pull-only: four conditions push one Discord embed each, and the rule that selects them is the decision** — silent on every Reader surface, wrong, repairable only by the owner, and holding past `ownerWindow` (12h). `stall` (#117's test **amended** with `AND refused = 0`, because the `refusals >= 2` break at poller.go:301-306 is not an early return and so read as a stall, double-firing on a newly-gated Site), `no-browser-route` (a Site with no browser route refusing >12h — the comix 2026-08-12 event, repaired by a deploy; a *browser* Site refusing sends nothing, per ADR-0010), `sidecar-down` (>12h — **invisible to the stall test**, since poller.go:234/245 return skip values, so "asleep an hour" and "gone a week" look alike), `adapter-broken` (**over half** of a Site's Series holding a #127 `no_chapter` row >12h — a share so a 4-Series and a 200-Series Site both fire). Never: a browser Site's refusal, a pause, one failing Series, an ageing forced Poll, a gated cover host. Fires **in the deferred `poll_passes` insert** — the only point every return path crosses — and needs **no timer**, since `Poller.lane` already wakes at least hourly (the ticket's "no clock" premise was wrong). Suppression is new `owner_notices(kind, site, notified_at)`, one row per episode cleared when the condition lifts; `site` is **the empty string for `sidecar-down`** so three browser Lanes send one message, the row's presence being the lock — which is why it is a table, not columns on `poll_lanes`. Thresholds stay **derived** from `poll_passes` and `failing_since`; the only new durable fact is that the owner was told. `DISCORD_WEBHOOK_URL`, **unset means off** (as `BROWSER_WS_URL`), never logged, and it touches no part of the OAuth login path; a failed POST logs and leaves `notified_at` unset, so the next pass retries — no queue. Presentation is an **embed**, colour `13589581` (dark `--danger`), title carrying the `PUBLIC_BASE_URL` deep link, one-sentence description, Discord timestamp, machine word in the footer, **no field grid**; `--ember` stays forbidden. Test seam is `Poller.Notifier`, nil meaning off. **Amends #115 and #122**: the landing verdict line must read the same four conditions. Rejected transports: Telegram (a second identity), ntfy.sh public (topics are public by design, 250/day), self-hosted ntfy/Gotify (the infrastructure this refuses). `CONTEXT.md` gains **Stall**; no ADR (reversible by deletion). ## Not yet specified _Empty — the way to the destination is charted; nothing in scope remains unspecified._ ## Out of scope - Multiple admins, a role column, or per-user admin grants — the single-owner model is the destination's boundary. - Inspecting an individual Reader's library or reading progress. Ruled out by the privacy boundary; only anonymous counts cross into admin views. - Metrics, charts, or a timeseries store. A swapless 1974 MiB VPS running Postgres argues against it, and no decision on this map needs a graph. - A monitor outside the backend (an external prober against `/healthz`, Uptime Kuma, healthchecks.io). A push cannot report its own process's death, so the honest coverage lives outside — but it is another service to run, watch and update, and it watches a fault that is **already visible from the Reader surfaces**: the web UI dies visibly, and the userscript degrades to its cache with `⟳ N pending` in the panel (manga-bookmark.user.js:1313-1314), losing no Progress. Downtime therefore costs a delay and is found within one reading session. Ruled out while resolving #132. - Bulk rewrite of `series_url` across a whole Site. A host change (asuracomic -> asurascans) invalidates every row of a Site at once, and [Data-correction actions: Latest Chapter and series_url repair](https://gitea.violetcrown.my.id/sulthan/mangaBookmark/issues/121) already ruled that case a SQL migration rather than a dashboard control — the dashboard's library-wide job is *finding* broken rows, not batch-repairing them. Was a fog line; it is a scope boundary, not an undiscovered decision, so it never graduates.
sulthan added the wayfinder:map label 2026-08-17 15:21:29 +07:00
Author
Owner

Charted. The map's destination is reached: four specs, all labelled ready-for-agent, in dependency order.

  • #134 — admin dashboard: information architecture and routes, Lane observability, the poll pass log (replacing the in-memory LaneReporter), the filterable Series list, the Series detail page, force-poll and pause through the database. Blocked by nothing.
  • #135 — owner data-correction actions: Latest Chapter correction, series_url repair behind fetchableSeriesURL, forced Cover replace, orphan Series removal. Blocked by #134.
  • #136 — Finished belongs to the Series: series.finished_at owns the poll gate, the Reader-facing Lifecycle finished bucket is deleted, one-way migration seeded from today's buckets. Blocked by #134.
  • #137 — per-Series Poll knowledge: poll_failures (the row's existence is the failure state), the failing/unverified filters, the six-Site completion hint, and the four-condition Discord notification. Blocked by #134, #135, #136.

Seams confirmed with the owner before writing: the router over a real store is the primary seam for everything the owner sees; the poller's round-at-a-time entry point for everything a Lane writes; the store and wire seams unchanged. Seam count goes down — web.LaneReporter and its fake die with the pass log. Exactly one seam is added, Poller.Notifier with nil meaning off, matching the existing fetcher field injection.

Split at four rather than one, on the owner's call: each spec is independently shippable and none is a monolith no agent finishes in one pass.

Charted. The map's destination is reached: four specs, all labelled `ready-for-agent`, in dependency order. - #134 — admin dashboard: information architecture and routes, Lane observability, the poll pass log (replacing the in-memory `LaneReporter`), the filterable Series list, the Series detail page, force-poll and pause through the database. Blocked by nothing. - #135 — owner data-correction actions: Latest Chapter correction, `series_url` repair behind `fetchableSeriesURL`, forced Cover replace, orphan Series removal. Blocked by #134. - #136 — Finished belongs to the Series: `series.finished_at` owns the poll gate, the Reader-facing Lifecycle `finished` bucket is deleted, one-way migration seeded from today's buckets. Blocked by #134. - #137 — per-Series Poll knowledge: `poll_failures` (the row's existence *is* the failure state), the `failing`/`unverified` filters, the six-Site completion hint, and the four-condition Discord notification. Blocked by #134, #135, #136. Seams confirmed with the owner before writing: the **router** over a real store is the primary seam for everything the owner sees; the poller's round-at-a-time entry point for everything a Lane writes; the store and wire seams unchanged. Seam count goes *down* — `web.LaneReporter` and its fake die with the pass log. Exactly one seam is added, `Poller.Notifier` with `nil` meaning off, matching the existing fetcher field injection. Split at four rather than one, on the owner's call: each spec is independently shippable and none is a monolith no agent finishes in one pass.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sulthan/mangaBookmark#114