Notification outside the page: is the dashboard pull-only? #132

Closed
opened 2026-08-21 14:04:51 +07:00 by sulthan · 1 comment
Owner

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:

  • A stalled Lane is due > 0 AND checked = 0 AND skip = '' on #117's poll_passes — that ticket established this is the only true stall.
  • A Site's mood is durable poll_lanes.refuse_until, and a deliberate pause is poll_lanes.paused_until (#119).
  • A Forced Poll that is never served ages visibly as force_poll_at > latest_checked_at (#119) — the stuck-Lane signal.
  • Per-Series failure is #127's poll_failures row plus its failing_since age.
  • The landing page already leads with one verdict line aggregating all of it (#115, #122).

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), and OWNER_DISCORD_ID (main.go:108) already names the one person a notification would be for. There is also no clock: no time.Ticker and no time.NewTimer anywhere 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:

  • Whether push is wanted at all, or whether a pull-only dashboard is the destination's boundary. The counter-case is that the owner reads the dashboard when they read their library, and a Lane that refuses for six hours costs nothing but a late chapter mark.
  • If yes: which condition is worth waking someone for. A stall is not the same as a refusal, and a refusal is the Site exercising its own pace (ADR-0010), not a fault.
  • Where it fires from, given the Lane goroutine is the only clock. A Lane that is stalled may be the one that would have to notice.
  • Repeat suppression. A condition that holds for a day must not send a message per pass (~120 passes/day per #117), and the state that remembers "already told them" is a new durable fact.
  • The transport, and whether reusing the Discord app for an outbound DM or webhook drags the login path into an availability concern it does not have today.
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: - A stalled Lane is `due > 0 AND checked = 0 AND skip = ''` on #117's `poll_passes` — that ticket established this is the *only* true stall. - A Site's mood is durable `poll_lanes.refuse_until`, and a deliberate pause is `poll_lanes.paused_until` (#119). - A Forced Poll that is never served ages visibly as `force_poll_at > latest_checked_at` (#119) — the stuck-Lane signal. - Per-Series failure is #127's `poll_failures` row plus its `failing_since` age. - The landing page already leads with one verdict line aggregating all of it (#115, #122). 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), and `OWNER_DISCORD_ID` (main.go:108) already names the one person a notification would be for. There is also **no clock**: no `time.Ticker` and no `time.NewTimer` anywhere 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: - Whether push is wanted at all, or whether a pull-only dashboard is the destination's boundary. The counter-case is that the owner reads the dashboard when they read their library, and a Lane that refuses for six hours costs nothing but a late chapter mark. - If yes: which condition is worth waking someone for. A stall is not the same as a refusal, and a refusal is the Site exercising its own pace (ADR-0010), not a fault. - Where it fires from, given the Lane goroutine is the only clock. A Lane that is stalled may be the one that would have to notice. - Repeat suppression. A condition that holds for a day must not send a message per pass (~120 passes/day per #117), and the state that remembers "already told them" is a new durable fact. - The transport, and whether reusing the Discord app for an outbound DM or webhook drags the login path into an availability concern it does not have today.
sulthan added the wayfinder:grilling label 2026-08-21 14:04:51 +07:00
sulthan self-assigned this 2026-08-21 14:09:13 +07:00
Author
Owner

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 localStorage cache, a write that meets a dropped connection goes to the retry queue, queueEnqueue shows a message only for a 400, a 401 or a full queue, and the sole visible marker is ⟳ N pending in the panel (manga-bookmark.user.js:1313-1314). No Progress is lost — the only loss path is eviction at QUEUE_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 constant

  1. stall — due > 0 AND checked = 0 AND skip = '' AND refused = 0 on #117's poll_passes.
  2. no-browser-route — a Site with no browser route refusing for more than 12h. read.go:55-69 maps both a 403 (cf-mitigated) and an interstitial body to errChallengeHeld on the plain-TLS path as well, so this is refused in #117's taxonomy plus durable poll_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.
  3. 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: runLanePass returns early at browserDownFor (poller.go:234) and at f == 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.
  4. adapter-broken — more than half of one Site's Series hold a #127 poll_failures row with outcome no_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=failing is 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

  • The stall test caught a refusing Site. The refusals >= 2 break (poller.go:301-306) is not an early return — the pass falls through to return gap at line 341, writing due > 0, checked = 0, skip = ''. A newly-gated Site would have sent both stall and no-browser-route. This amends #117's stall definition, which this ticket's own body restated: the test gains AND 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.
  • One dead sidecar is three Lanes. kagane, comix and novelfull each notice the loss on their own pass. owner_notices.site therefore holds the empty string for sidecar-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 on poll_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_passes insert, in the deferred call every return path of runLanePass passes 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 than defaultRest — 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 from poll_passes, a Series failure's age from poll_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 as BROWSER_WS_URL behaves (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 of TOKEN_KEY and 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_at is 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; --ember is forbidden here (#122: no ember on admin), and no second colour is introduced because no non-fault condition pushes. title = the subject (the Site, or browser sidecar) carrying the deep link built from PUBLIC_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, nil meaning off — the pattern Fetch and BrowserFetch already 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

  • #117 — the stall test gains AND refused = 0 (see above).
  • #115 and #122 — the landing verdict line must read the same four conditions. A message naming a fault the page cannot explain sends the owner to a page that looks healthy. Push and page stay in step.

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.md gains 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.

## 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 `localStorage` cache, a write that meets a dropped connection goes to the retry queue, `queueEnqueue` shows a message only for a 400, a 401 or a full queue, and the sole visible marker is `⟳ N pending` in the panel (manga-bookmark.user.js:1313-1314). No Progress is lost — the only loss path is eviction at `QUEUE_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 constant 1. **`stall`** — `due > 0 AND checked = 0 AND skip = '' AND refused = 0` on #117's `poll_passes`. 2. **`no-browser-route`** — a Site with **no browser route** refusing for more than 12h. `read.go:55-69` maps both a 403 (`cf-mitigated`) and an interstitial body to `errChallengeHeld` on the plain-TLS path as well, so this is `refused` in #117's taxonomy plus durable `poll_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. 3. **`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**: `runLanePass` returns early at `browserDownFor` (poller.go:234) and at `f == 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. 4. **`adapter-broken`** — more than **half** of one Site's Series hold a #127 `poll_failures` row with outcome `no_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=failing` is 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 - **The stall test caught a refusing Site.** The `refusals >= 2` break (poller.go:301-306) is *not* an early return — the pass falls through to `return gap` at line 341, writing `due > 0, checked = 0, skip = ''`. A newly-gated Site would have sent both `stall` and `no-browser-route`. **This amends #117's stall definition, which this ticket's own body restated: the test gains `AND 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. - **One dead sidecar is three Lanes.** kagane, comix and novelfull each notice the loss on their own pass. `owner_notices.site` therefore holds **the empty string** for `sidecar-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 on `poll_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_passes` insert, **in the deferred call** every return path of `runLanePass` passes 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 than `defaultRest` — 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 from `poll_passes`, a Series failure's age from `poll_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 as `BROWSER_WS_URL` behaves (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 of `TOKEN_KEY` and 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_at` is 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; **`--ember` is forbidden here** (#122: no ember on admin), and no second colour is introduced because no non-fault condition pushes. `title` = the subject (the Site, or `browser sidecar`) carrying the deep link built from `PUBLIC_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`, `nil` meaning off** — the pattern `Fetch` and `BrowserFetch` already 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 - **#117** — the stall test gains `AND refused = 0` (see above). - **#115 and #122** — the landing verdict line must read the **same four conditions**. A message naming a fault the page cannot explain sends the owner to a page that looks healthy. Push and page stay in step. ### 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.md` gains **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.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sulthan/mangaBookmark#132