Notification outside the page: is the dashboard pull-only? #132
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Part of #114
Question
Does an unhealthy Poll Lane reach the owner outside the dashboard, or is the whole surface pull-only?
Graduated out of the map's fog with the closing of #131, which left the frontier empty. This is the last decision between the map and a handoff-ready spec, and it is now sharp because the detection half is fully specified — only the push is unexamined.
Detection is already decided, and it is durable. Every signal a notification could carry exists in the plan:
due > 0 AND checked = 0 AND skip = ''on #117'spoll_passes— that ticket established this is the only true stall.poll_lanes.refuse_until, and a deliberate pause ispoll_lanes.paused_until(#119).force_poll_at > latest_checked_at(#119) — the stuck-Lane signal.poll_failuresrow plus itsfailing_sinceage.So the question is not what would it say. It is whether the owner must open a page to hear it.
What exists in code today (2026-08-21): nothing outbound. No webhook, no notifier, no mail path — the only Discord surface is OAuth login (
DISCORD_CLIENT_ID/DISCORD_CLIENT_SECRET/DISCORD_GUILD_ID, main.go:114-119), andOWNER_DISCORD_ID(main.go:108) already names the one person a notification would be for. There is also no clock: notime.Tickerand notime.NewTimeranywhere in the backend, and #117 deliberately kept it that way by pruning from the Lane goroutine. A push therefore has exactly one place to fire from — inside a Lane pass — unless this ticket adds the first timer.To decide:
Answer
The dashboard is not pull-only. Four conditions push one Discord embed each, and nothing else does.
Push exists for one class of fault: silent on every Reader surface, wrong, repairable only by the owner, and persisting past 12 hours. That rule is the decision — the four conditions are what it currently selects, and a fifth candidate must pass all four tests to join them.
Why push at all
A backend that stopped is not the case worth pushing, and the reason came from the Reader surfaces, not from the plan. The web UI dies visibly. The userscript degrades quietly but honestly: reads come from the
localStoragecache, a write that meets a dropped connection goes to the retry queue,queueEnqueueshows a message only for a 400, a 401 or a full queue, and the sole visible marker is⟳ N pendingin the panel (manga-bookmark.user.js:1313-1314). No Progress is lost — the only loss path is eviction atQUEUE_MAX, which toasts. So downtime costs a delay, is found within one reading session, and other Readers find it too.A stalled Lane is the opposite: every surface works, Latest Chapter quietly stops moving, no ember lights, and a quiet week on the Site looks identical. There is no reason to open
/admin, which is precisely why the fault survives.The four conditions, each at
ownerWindow(12h) — no new constantstall—due > 0 AND checked = 0 AND skip = '' AND refused = 0on #117'spoll_passes.no-browser-route— a Site with no browser route refusing for more than 12h.read.go:55-69maps both a 403 (cf-mitigated) and an interstitial body toerrChallengeHeldon the plain-TLS path as well, so this isrefusedin #117's taxonomy plus durablepoll_lanes.refuse_until. This is the comix.to event of 2026-08-12, and the repair is a deploy. A browser Site refusing sends nothing — that is an ordinary challenge that did not clear, and per ADR-0010 a Site setting its own pace is not a fault.sidecar-down— no browser Lane has reached the sidecar for more than 12h. This is not a stall and cannot be found by the stall test:runLanePassreturns early atbrowserDownFor(poller.go:234) and atf == nil(poller.go:245), and #117 records both as skip values. The map made this degrade quietly on purpose (ADR-0005, ADR-0006) — but "asleep for an hour" and "gone for a week" are indistinguishable to the backend, and the second freezes kagane and comix marks.adapter-broken— more than half of one Site's Series hold a #127poll_failuresrow with outcomeno_chapter, older than 12h. A layout change gives!facts.HasLatest(poller.go:458); one such Series is a bad row, most of a Site is a broken adapter. A share, not a count, so a 4-Series Site and a 200-Series Site both fire. This is the one fault on the map that no other signal names.Never pushed: a refusal by a browser Site (not a fault), a pause (
poll_lanes.paused_until— the owner's own act), a single failing Series (?filter=failingis the tool, and one Site change makes hundreds of rows), a forced Poll ageing (the same fault from the other end, and visible on the page the button lives on), a cover host being gated (a missing Cover is visible in the UI).Two collisions found in the code, and their fixes
refusals >= 2break (poller.go:301-306) is not an early return — the pass falls through toreturn gapat line 341, writingdue > 0, checked = 0, skip = ''. A newly-gated Site would have sent bothstallandno-browser-route. This amends #117's stall definition, which this ticket's own body restated: the test gainsAND refused = 0. Rejected alternative: a tenth skip value, because #117's enum holds one value per return path and this is a break inside one — a tenth value would describe a pass that did run.owner_notices.sitetherefore holds the empty string forsidecar-down: the first Lane to notice writes the row, the other two find it present and stay quiet. The row's own presence is the lock — which is exactly why the state is its own table and not columns onpoll_lanes, where an empty Site name has no meaning. Rejected: naming one Lane the reporter, since that Lane's pass may be the skipped one.Where it fires from
Beside #117's
poll_passesinsert, in the deferred call every return path ofrunLanePasspasses through. Two of the four conditions occur on early returns (poller.go:229, poller.go:234), so the success path can never see them. No new timer:Poller.lane(poller.go:195-207) already does one pass, then waits the pace the pass reported, which is never more thandefaultRest— each Lane is its own clock at least hourly. The ticket body's claim that the backend has no clock was wrong.Suppression
New table
owner_notices(kind, site, notified_at)— one row per episode, cleared when the condition no longer holds, so a fault lasting a month sends one message rather than 30 (~120 passes/day per #117). Every threshold is derived, not stored: a refusal's and a sidecar absence's age frompoll_passes, a Series failure's age frompoll_failures.failing_since. The only new durable fact is that the owner was told, which is why the table carries nothing else. Rows are bounded by four kinds × six Sites, so no retention rule.Transport
DISCORD_WEBHOOK_URL; unset means the whole path is off, logged once at start, exactly asBROWSER_WS_URLbehaves (main.go:244) — a local stack must not need a webhook to run. A webhook is a plain POST and does not touch the login path: no bot, no gateway, no scope, not the OAuth application, so ADR-0002's Discord coupling gains nothing new. The address is a secret in the class ofTOKEN_KEYand is never logged. Rate limits are irrelevant at this volume (~30 messages/min per webhook allowed; this map sends a few per day).A failed POST is logged and
notified_atis left unset, so the next pass retries while the condition holds. No queue, no backoff: the condition is durable, so the retry is free. (The userscript needs a queue because a Reader's Progress is otherwise lost; a message about a durable fact is not.)Free alternatives considered and rejected (measured 2026-08-21): Telegram bot — free and unlimited at this volume, but a second identity to hold when
OWNER_DISCORD_ID(main.go:108) already names the recipient; ntfy.sh public server — 250 messages/day, 60 burst, but every topic is public by design (no authentication, anyone knowing the topic can read and publish) and retention is limited; self-hosted ntfy or Gotify — a container and a volume, which is the infrastructure this decision refuses.Presentation
A Discord embed, colour
13589581— the dark branch of--danger(#cf5c4d). One colour for all four, because all four are faults;--emberis forbidden here (#122: no ember on admin), and no second colour is introduced because no non-fault condition pushes.title= the subject (the Site, orbrowser sidecar) carrying the deep link built fromPUBLIC_BASE_URL(main.go:106 — no new environment value), so no body line is spent on an address.description= one sentence: condition, age, repair.timestamp= the pass time, which Discord renders in the reader's own zone — the one thing an embed does better than plain text.footer= the machine word (stall,no-browser-route,sidecar-down,adapter-broken), making an old message searchable. No field grid, no thumbnail, no author block — the figures belong on the page the title links to. Only the colour of Cinder survives into Discord; the typography cannot.Test seam
Poller.Notifier func(...) error,nilmeaning off — the patternFetchandBrowserFetchalready use, so all four conditions are testable with no wire and no Discord. One separate test covers the embed payload.Amendments this pushes onto closed tickets
AND refused = 0(see above).Out of scope, decided here
A monitor outside the backend. A push cannot report its own process's death — every signal is computed inside it, so silence is indistinguishable from health. The honest coverage is an external prober against
/healthz, which is another service to run, watch and update, and it watches a fault that is already visible from the Reader surfaces and costs only a delay. Recorded on the map's Out of scope section.Glossary
CONTEXT.mdgains Stall, the condition this whole ticket is built on and the only one of the four that is a domain fact rather than a deployment fact. No ADR: the decision is reversible by deleting the path, so it fails the hardness test.