Outbound owner notification, and the stall it exists for #171

Closed
opened 2026-08-22 17:40:01 +07:00 by sulthan · 1 comment
Owner

Parent

Part of #137.

What to build

The dashboard stops being pull-only, for exactly one class of fault: silent on every Reader surface, wrong, repairable only by the owner, and persisting past twelve hours. That rule is the decision — the conditions are only what it selects, and a fifth candidate must pass all four tests.

The first condition is a stall: a Lane that owed Polls, made none, and has nothing to say for it. Every other fault finds itself within one reading session — the web UI dies visibly, the userscript degrades to its cache with a pending count and loses no Progress — so backend downtime is deliberately not pushed.

This ticket builds the whole path plus that one condition. No new timer: each Lane already wakes at least hourly and is its own clock.

Acceptance criteria

  • DISCORD_WEBHOOK_URL is read at startup; unset means the whole path is off, logged once, exactly as the browser URL behaves — a local stack must not need a webhook to run. The variable is added to .env.example and listed in the compose service environment (an env var not listed there never reaches the container), with an empty default and never a required-guard.
  • The address is a secret in the class of the token key: never logged, and it must not reach any line that prints configuration.
  • One new seam only: an injectable notifier on the poller with nil meaning off — the same field-injection pattern the fetchers already use, so every condition is testable with no wire and no Discord.
  • A new table remembers only that the owner was told, one row per episode, cleared when the condition no longer holds — a fault lasting a month sends one message, not thirty at ~120 passes a day. Every threshold is derived, not stored. Rows are bounded, so no retention rule.
  • It fires beside the pass-log insert, in the deferred call every return path passes through — two of the four conditions occur on early returns and the success path can never see them.
  • The stall test is due > 0, none checked, no skip value, and no refusal. That last clause is the amendment: the twice-refused break is inside the pass loop, not an early return, so a newly-gated Site would otherwise send two messages. Rejected alternative: a new skip value, because the vocabulary holds one value per return path and this is a break inside one.
  • The message is a Discord embed: the dark danger colour (never ember), the subject as a deep-linked title built from the existing public base URL, one sentence giving condition, age and repair, the pass time as the timestamp so Discord renders it in the owner's own zone, and the machine word in the footer so an old message is searchable. No field grid, no thumbnail, no author block.
  • A failed POST is logged and the notified stamp left unset, so the next pass retries while the condition holds. No queue, no backoff — the condition is durable.
  • The path imports nothing from the session or OAuth packages. That independence is why a webhook was chosen over a bot.
  • A pause sends nothing: the owner's own act is not reported back.
  • Tests: the condition fires exactly once while it holds and again after it lifts and returns; a stall on a Site that also refused fires nothing from this test (the collision a naive implementation gets wrong); a failed send retries next pass. One separate test covers the embed payload — colour, title link, footer word, timestamp, and the absence of a field grid.
  • Rollout note in the PR: the notice table starts empty, so the first pass after deploy sends for conditions already true. Correct per one-row-per-episode; say so rather than have it reported as a bug. Also state the prod step — create the webhook, set the key on the deployment, redeploy — because unset is silent by design and the feature otherwise ships dark.

Blocked by

  • #165 — poll_failures: the row is the failure state
## Parent Part of #137. ## What to build The dashboard stops being pull-only, for exactly one class of fault: silent on every Reader surface, wrong, repairable only by the owner, and persisting past twelve hours. That rule is the decision — the conditions are only what it selects, and a fifth candidate must pass all four tests. The first condition is a stall: a Lane that owed Polls, made none, and has nothing to say for it. Every other fault finds itself within one reading session — the web UI dies visibly, the userscript degrades to its cache with a pending count and loses no Progress — so backend downtime is deliberately **not** pushed. This ticket builds the whole path plus that one condition. No new timer: each Lane already wakes at least hourly and is its own clock. ## Acceptance criteria - [ ] `DISCORD_WEBHOOK_URL` is read at startup; unset means the whole path is off, logged once, exactly as the browser URL behaves — a local stack must not need a webhook to run. The variable is added to `.env.example` **and** listed in the compose service environment (an env var not listed there never reaches the container), with an empty default and never a required-guard. - [ ] The address is a secret in the class of the token key: never logged, and it must not reach any line that prints configuration. - [ ] One new seam only: an injectable notifier on the poller with nil meaning off — the same field-injection pattern the fetchers already use, so every condition is testable with no wire and no Discord. - [ ] A new table remembers only *that the owner was told*, one row per episode, cleared when the condition no longer holds — a fault lasting a month sends one message, not thirty at ~120 passes a day. Every threshold is derived, not stored. Rows are bounded, so no retention rule. - [ ] It fires beside the pass-log insert, in the deferred call every return path passes through — two of the four conditions occur on early returns and the success path can never see them. - [ ] The stall test is `due > 0`, none checked, no skip value, **and no refusal**. That last clause is the amendment: the twice-refused break is inside the pass loop, not an early return, so a newly-gated Site would otherwise send two messages. Rejected alternative: a new skip value, because the vocabulary holds one value per *return* path and this is a break inside one. - [ ] The message is a Discord embed: the dark danger colour (never ember), the subject as a deep-linked title built from the existing public base URL, one sentence giving condition, age and repair, the pass time as the timestamp so Discord renders it in the owner's own zone, and the machine word in the footer so an old message is searchable. No field grid, no thumbnail, no author block. - [ ] A failed POST is logged and the notified stamp left unset, so the next pass retries while the condition holds. No queue, no backoff — the condition is durable. - [ ] The path imports nothing from the session or OAuth packages. That independence is why a webhook was chosen over a bot. - [ ] A pause sends nothing: the owner's own act is not reported back. - [ ] Tests: the condition fires exactly once while it holds and again after it lifts and returns; a stall on a Site that also refused fires nothing from this test (the collision a naive implementation gets wrong); a failed send retries next pass. One separate test covers the embed payload — colour, title link, footer word, timestamp, and the absence of a field grid. - [ ] Rollout note in the PR: the notice table starts empty, so the first pass after deploy sends for conditions already true. Correct per one-row-per-episode; say so rather than have it reported as a bug. Also state the prod step — create the webhook, set the key on the deployment, redeploy — because unset is silent by design and the feature otherwise ships dark. ## Blocked by - #165 — poll_failures: the row is the failure state
sulthan added the ready-for-agent label 2026-08-22 17:40:01 +07:00
sulthan self-assigned this 2026-08-22 23:30:53 +07:00
Author
Owner

Landed on spec-137 at 3d74609. Outbound owner notification path: DISCORD_WEBHOOK_URL env (unset = off, never logged), owner_notices suppression table (migration 0020), Notifier seam on Poller (nil = off), FaultsFrom/Fault/ConditionStall seam in internal/latest for #172/#173 to consume, internal/notify Discord embed client (stdlib only, danger colour, no OAuth/session import). Stall fires once per episode, clears on recovery, excludes refused/paused. Rollout note: owner_notices starts empty, first pass after deploy sends for pre-existing conditions; prod step is create webhook + set DISCORD_WEBHOOK_URL + redeploy. Tests: faults/poller/notify/store/main all green. Review: 1 hard violation (duplicate compose depends_on) + AC12 rollout note gap, both fixed in 6af8859; 3 judgement calls fixed, humanAge boundary tests declined (cosmetic formatter).

Landed on spec-137 at 3d74609. Outbound owner notification path: DISCORD_WEBHOOK_URL env (unset = off, never logged), owner_notices suppression table (migration 0020), Notifier seam on Poller (nil = off), FaultsFrom/Fault/ConditionStall seam in internal/latest for #172/#173 to consume, internal/notify Discord embed client (stdlib only, danger colour, no OAuth/session import). Stall fires once per episode, clears on recovery, excludes refused/paused. Rollout note: owner_notices starts empty, first pass after deploy sends for pre-existing conditions; prod step is create webhook + set DISCORD_WEBHOOK_URL + redeploy. Tests: faults/poller/notify/store/main all green. Review: 1 hard violation (duplicate compose depends_on) + AC12 rollout note gap, both fixed in 6af8859; 3 judgement calls fixed, humanAge boundary tests declined (cosmetic formatter).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sulthan/mangaBookmark#171