Per-Series Poll outcome state and the failing filter #127

Closed
opened 2026-08-17 19:33:58 +07:00 by sulthan · 5 comments
Owner

Part of #114
Blocked by: #117

Question

What does a Series row remember about its own last Poll, and how does a persistently-failing
Series become findable?

#117 persists a Lane Pass log: per-Site outcome counts over a 12h window, plus restart-proof
Lane state. It says nothing durable about one Series, and that leaves a hole in #116's
filter set.

The hole, concretely: MarkLatestChecked stamps latest_checked_at before the fetch
(poller.go:428), deliberately — otherwise a renamed or deleted Series is retried on every pass
forever. So the column means attempted, never succeeded. A Series whose page has quietly
broken (markup change, wrong series_url, permanent 404) is stamped every hour and reads as
perfectly healthy: unchecked misses it (it has a timestamp), stale misses it (the timestamp
is minutes old), unpollable misses it (it has a series_url). The only trace is log lines
nobody reads, and #117's per-Site count rolls off in 12h without naming the Series.

Expected shape going in: three columns on series — last outcome, when, and a
consecutive-failure counter that resets on success — written by checkOne. Current state,
not history; the log is #117's job.

To decide:

  • The column set and the outcome vocabulary. #117 fixed a five-way pass-level taxonomy
    (refused / unreachable / no_chapter / unfetchable / errors); whether a Series row
    reuses it verbatim or needs a coarser one is open.
  • Where the write goes. The stamp happens before the fetch, so the outcome cannot ride along
    with it — this is a second UPDATE per check. Whether a successful Poll pays for it at all
    (a guarded update that no-ops when the counter is already zero) is a real choice, not a
    micro-optimisation: it is the difference between one extra write per Poll and one extra write
    per failure.
  • What the eighth filter name is (#116 fixed seven), what its predicate is, and what threshold
    makes a Series "failing" rather than "failed once".
  • What the Series detail page and the Series row render — a streak, a first-failure date, or
    the outcome word.
  • Whether a Forced Poll (#119) or a repair (#121) clears the counter, or only a successful Poll.
  • Whether SightingRaised and a failure streak on the same row mean something together: a
    Series the Poll cannot read, whose Latest Chapter came from a Reader's Sighting, is trusting
    an unverifiable number for as long as the failure lasts.
Part of #114 Blocked by: #117 ## Question What does a Series row remember about its own last Poll, and how does a persistently-failing Series become findable? #117 persists a Lane Pass log: per-Site outcome counts over a 12h window, plus restart-proof Lane state. It says nothing durable about **one** Series, and that leaves a hole in #116's filter set. The hole, concretely: `MarkLatestChecked` stamps `latest_checked_at` **before** the fetch (poller.go:428), deliberately — otherwise a renamed or deleted Series is retried on every pass forever. So the column means *attempted*, never *succeeded*. A Series whose page has quietly broken (markup change, wrong `series_url`, permanent 404) is stamped every hour and reads as perfectly healthy: `unchecked` misses it (it has a timestamp), `stale` misses it (the timestamp is minutes old), `unpollable` misses it (it has a `series_url`). The only trace is log lines nobody reads, and #117's per-Site count rolls off in 12h without naming the Series. Expected shape going in: three columns on `series` — last outcome, when, and a **consecutive-failure counter that resets on success** — written by `checkOne`. Current state, not history; the log is #117's job. To decide: - The column set and the outcome vocabulary. #117 fixed a five-way pass-level taxonomy (`refused` / `unreachable` / `no_chapter` / `unfetchable` / `errors`); whether a Series row reuses it verbatim or needs a coarser one is open. - Where the write goes. The stamp happens before the fetch, so the outcome cannot ride along with it — this is a second `UPDATE` per check. Whether a successful Poll pays for it at all (a guarded update that no-ops when the counter is already zero) is a real choice, not a micro-optimisation: it is the difference between one extra write per Poll and one extra write per failure. - What the eighth filter name is (#116 fixed seven), what its predicate is, and what threshold makes a Series "failing" rather than "failed once". - What the Series detail page and the Series row render — a streak, a first-failure date, or the outcome word. - Whether a Forced Poll (#119) or a repair (#121) clears the counter, or only a successful Poll. - Whether `SightingRaised` and a failure streak on the same row mean something together: a Series the Poll cannot read, whose Latest Chapter came from a Reader's Sighting, is trusting an unverifiable number for as long as the failure lasts.
sulthan added the wayfinder:grilling label 2026-08-17 19:33:58 +07:00
Author
Owner

Constraints from #118 (closed):

  • no-chapter is taken. #118 added an eighth filter for latest_chapter_num IS NULL AND latest_checked_at > 0 AND series_url <> '' — a Series that has never once succeeded. So
    failing is the ninth name and must mean something else: a Series that succeeded at least
    once and has been failing since. If the threshold specified here would also match a
    never-succeeded Series, the two filters overlap for no gain.
  • This ticket owns a link target. #117's five per-Site outcome counts (refused /
    unreachable / no_chapter / unfetchable / errors) render unlinked until failing exists,
    because the pass row stores counts and never identities. Once it does, all five link to
    /admin/series?site=<site>&filter=failing. Deliberately not linking no_chapter to
    ?filter=no-chapter in the meantime: the count means attempts that read no chapter in the last
    12h, the filter means never once succeeded, and a Series that broke this morning is in one and
    not the other.
  • A Latest Chapter regression is not a filter. #118 rejected it outright (nothing stores the
    previous value; a downward write is the correction, asserted by poller_test.go:481-497). If a
    column here would make regression detectable as a side effect, that is a consequence to state,
    not a requirement to satisfy.
Constraints from #118 (closed): - **`no-chapter` is taken.** #118 added an eighth filter for `latest_chapter_num IS NULL AND latest_checked_at > 0 AND series_url <> ''` — a Series that has *never once* succeeded. So `failing` is the **ninth** name and must mean something else: a Series that succeeded at least once and has been failing since. If the threshold specified here would also match a never-succeeded Series, the two filters overlap for no gain. - **This ticket owns a link target.** #117's five per-Site outcome counts (`refused` / `unreachable` / `no_chapter` / `unfetchable` / `errors`) render unlinked until `failing` exists, because the pass row stores counts and never identities. Once it does, all five link to `/admin/series?site=<site>&filter=failing`. Deliberately *not* linking `no_chapter` to `?filter=no-chapter` in the meantime: the count means attempts that read no chapter in the last 12h, the filter means never once succeeded, and a Series that broke this morning is in one and not the other. - **A Latest Chapter regression is not a filter.** #118 rejected it outright (nothing stores the previous value; a downward write is the correction, asserted by `poller_test.go:481-497`). If a column here would make regression detectable as a side effect, that is a consequence to state, not a requirement to satisfy.
Author
Owner

Constraint from #121 (closed):

A repair must not clear a failure counter or a last-outcome column. #121's Latest Chapter
correction writes latest_chapter, latest_chapter_num and latest_corrected_at and nothing else:
the owner typing a number is not evidence the Series' page became readable, so only a successful
Poll may reset whatever this ticket adds. That answers one of the open bullets here in the negative
from #121's side; the Forced Poll half (#119) is still yours to decide.

Two more things #121 fixed that touch this ticket:

  • #79 is not a defect. It settled that the chapter-list maximum is Latest Chapter and
    pre-rejected capping it, so there is no wrong-number class to detect — which also means a
    correction is not a signal of a broken adapter and must not be treated as one.
  • The unverifiable-Sighting pair now has a remedy. Where a Series carries latest_raised_by and
    the Poll cannot read the page, the owner can correct the number by hand and the correction calls
    ClearSightingAttribution (never RecordSightingOutcome — a mark needs 20 confirming Polls this
    class will never get). So if you decide SightingRaised + a failure streak means something
    together, the action it should lead to already exists and lives on the Series detail page.
Constraint from #121 (closed): **A repair must not clear a failure counter or a last-outcome column.** #121's Latest Chapter correction writes `latest_chapter`, `latest_chapter_num` and `latest_corrected_at` and nothing else: the owner typing a number is not evidence the Series' page became readable, so only a successful Poll may reset whatever this ticket adds. That answers one of the open bullets here in the negative from #121's side; the Forced Poll half (#119) is still yours to decide. Two more things #121 fixed that touch this ticket: - **#79 is not a defect.** It settled that the chapter-list maximum *is* Latest Chapter and pre-rejected capping it, so there is no wrong-number class to detect — which also means a correction is *not* a signal of a broken adapter and must not be treated as one. - **The unverifiable-Sighting pair now has a remedy.** Where a Series carries `latest_raised_by` and the Poll cannot read the page, the owner can correct the number by hand and the correction calls `ClearSightingAttribution` (never `RecordSightingOutcome` — a mark needs 20 confirming Polls this class will never get). So if you decide `SightingRaised` + a failure streak means something together, the action it should lead to already exists and lives on the Series detail page.
Author
Owner

Amendment from #124 (closed):

  • A finished Series has no outcome to classify. series.finished_at > 0 means no Lane ever picks it, so it produces no Poll outcomes and never enters a failure streak. Your failing predicate is left alone deliberately — no AND s.finished_at = 0 guard — because a Series that was already failing when the owner finished it keeps its last recorded outcome as history, and one that starts finished never accumulates one. Guarding it would erase evidence rather than prevent a false figure.
  • Contrast with #118's four guarded predicates (stale, unchecked, no-cover, no-chapter): those are computed from clocks that keep ticking after the last Poll, so they lie about a finished row. Yours is computed from stored outcomes, which simply stop arriving.
  • failing stays the ninth filter name; finished is the tenth, ordered last as informational.
  • Your outcome write shares a question with #130 (the completion-marker hint): latest_checked_at is stamped before the fetch (poller.go:422-431), so anything derived from the page body needs its own write after the read. If both land, one write for both is worth considering — flagged there too.
  • Nothing in your stall vocabulary changes: a Site whose whole worklist is finished counts 0 eligible and logs #117's nothing-eligible, which is a Lane that declined and said why, not a stall.
Amendment from #124 (closed): - **A finished Series has no outcome to classify.** `series.finished_at > 0` means no Lane ever picks it, so it produces no Poll outcomes and never enters a failure streak. Your `failing` predicate is left alone deliberately — no `AND s.finished_at = 0` guard — because a Series that was already failing when the owner finished it keeps its last recorded outcome as history, and one that starts finished never accumulates one. Guarding it would erase evidence rather than prevent a false figure. - Contrast with #118's four guarded predicates (`stale`, `unchecked`, `no-cover`, `no-chapter`): those are computed from clocks that keep ticking after the last Poll, so they lie about a finished row. Yours is computed from stored outcomes, which simply stop arriving. - **`failing` stays the ninth filter name**; `finished` is the tenth, ordered last as informational. - **Your outcome write shares a question with #130** (the completion-marker hint): `latest_checked_at` is stamped *before* the fetch (`poller.go:422-431`), so anything derived from the page body needs its own write after the read. If both land, one write for both is worth considering — flagged there too. - Nothing in your stall vocabulary changes: a Site whose whole worklist is finished counts 0 eligible and logs #117's `nothing-eligible`, which is a Lane that declined and said why, not a stall.
Author
Owner

From #125 (closed): if per-Series Poll outcome state lands as its own table rather than columns on series, it must key (site, series_id) REFERENCES series ON DELETE CASCADE. Orphan removal is a plain DELETE FROM series guarded by nothing but bookmarks_series_fk, so a per-Series child table without a cascade turns that one statement into a multi-step delete and breaks the FK-as-orphan-test property. The cascade must reach poll history only — never bookmarks, whose refusal is the guard.

Also from #121, restated here: a human correction must not clear a failure counter or a last-outcome column.

From #125 (closed): if per-Series Poll outcome state lands as its own table rather than columns on `series`, it must key `(site, series_id) REFERENCES series ON DELETE CASCADE`. Orphan removal is a plain `DELETE FROM series` guarded by nothing but `bookmarks_series_fk`, so a per-Series child table without a cascade turns that one statement into a multi-step delete and breaks the FK-as-orphan-test property. The cascade must reach poll history only — never `bookmarks`, whose refusal *is* the guard. Also from #121, restated here: a human correction must not clear a failure counter or a last-outcome column.
sulthan self-assigned this 2026-08-20 20:35:11 +07:00
Author
Owner

Resolution

One table, poll_failures, holding a row only while a Series is failing — no columns on
series, no counter, no history. Two new filter names, failing (ninth) and unverified
(eleventh). The per-Site outcome counts stay unlinked for ever.

Migration 0017

CREATE TABLE poll_failures (
  site          text   NOT NULL,
  series_id     text   NOT NULL,
  outcome       text   NOT NULL,
  failing_since bigint NOT NULL,  -- unix ms, first failure of the current run
  PRIMARY KEY (site, series_id),
  FOREIGN KEY (site, series_id) REFERENCES series (site, series_id) ON DELETE CASCADE
);

The row's existence is the state. A row means "the last Poll that learned anything about
this Series failed"; no row means it succeeded, or the Series was never polled
(latest_checked_at = 0 separates that), or every Poll so far ended in an outcome the write
rule below ignores. So there is no '' success sentinel, no CHECK keeping two columns
consistent, and failing_since is never zero.

3NF was checked against both designs and does not decide between them: the key is
(site, series_id) either way, the relation is 1:1, and the one rule between the attributes
(a word implies an age) yields no value, so it is a constraint and not a transitive
dependency — both designs are in BCNF. A 1:1 table with the same key is vertical
partitioning, not normalisation, and a lookup table for the outcome vocabulary would be a
domain constraint a Go constant already enforces. The table was chosen on its own merits:
series stays narrow, the sentinel disappears, and the guarded write gets simpler.
The cascade is #125's requirement and reaches this table only — never bookmarks, whose
refusal is the orphan guard, so DELETE FROM series stays one statement.

The write: one statement per Poll, a row written only on a change

-- failure
INSERT INTO poll_failures (site, series_id, outcome, failing_since) VALUES ($1,$2,$3,$4)
ON CONFLICT (site, series_id) DO UPDATE SET outcome = excluded.outcome
  WHERE poll_failures.outcome <> excluded.outcome;
-- success
DELETE FROM poll_failures WHERE site = $1 AND series_id = $2;

failing_since survives a change of word because DO UPDATE never touches it — the age is
the age of the run of failures, not of the current word. The same failure repeating writes
nothing; a healthy Series polling correctly writes nothing. The extra cost lands on
transitions, not on Polls. This is why no last_outcome_at column exists: it would sit
within seconds of latest_checked_at, which is already stored.

Rejected: three columns on series (last_poll_outcome, failing_since, and a
consecutive-failure counter). The counter goes because it is a proxy for duration measured in
units that differ per Site — each Lane carries its own Rest, and a paused Lane stops the
count without stopping the failure. failing since <age> is the fact the owner reads.

Which outcomes write, and which are silent

The five-word taxonomy #117 fixed is reused verbatim, plus one split (below). But only
outcomes that are evidence about this Series touch the table
:

outcome writes? why
no_chapter yes HTTP 200, real HTML, latestChapterFrom found nothing (read.go:70, poller.go:458) — the Series' page or our adapter
unfetchable yes the stored series_url failed fetchableSeriesURL (read.go:44) — the Series' own address
not_found yes 4xx other than 403 — the page is gone
errors yes transport failure, 5xx, or a failed MarkLatestChecked
refused no statement at all errChallengeHeld: 403 or an interstitial body (read.go:55-69) — the Site's mood, identical for every Series it hosts
unreachable no statement at all errBrowserInterrupted — our own sidecar, and nothing was read

Neither insert nor delete for those two, and the distinction is load-bearing in both
directions. Writing a row would mark a whole library as failing when one Site refused for a
day. Deleting one would claim recovery when nothing was read — resetting failing_since to
zero for a Series broken three months, so a single refusal from its Site would erase the age
that makes it findable. A refused/unreachable Poll therefore issues one fewer query,
and the Series keeps the last thing it truly learned about itself. Both facts are already
recorded where they belong: per Site in #117's poll_lanes.refuse_until, and in the
poller's in-memory browserDownAt.

errors is split: not_found becomes the sixth outcome word, for a 4xx status other
than 403. Today one word covers a 404 (the page is gone — correct the address), a 503 (the
Site is busy — wait) and our own write failing (the database is unwell): three different
owner actions behind the one word the detail page prints. One branch in read.go, and #117's
poll_passes gains a sixth count column — free on paper, since nothing on this map is built.

Not written by anything else, ever: a Forced Poll (#119) request clears nothing, because
a request is not evidence; a forced Poll that reads the page clears the row exactly as any
other correct read does, which needs no special case in the code. A hand correction (#121)
clears nothing, as #121 required.

The two filter names

  • failing (ninth): the joined row exists AND s.latest_chapter_num IS NOT NULL AND
    f.failing_since older than ownerWindow (12h, #117's constant — no new figure).
    latest_chapter_num IS NOT NULL is what keeps it disjoint from #118's no-chapter
    (never once succeeded) rather than overlapping it for no gain. Per #124 there is
    deliberately no finished_at = 0 guard: a Series already failing when the owner
    finished it keeps its evidence.
  • unverified (eleventh, ordered beside sighting-raised):
    s.latest_raised_by IS NOT NULL plus the failing test — the Latest Chapter came from a
    Reader's Sighting and no Poll has confirmed it for over 12 hours. #121's correction
    control on the detail page is the action it leads to.

A Series failing for under 12 hours is in neither filter. That is deliberate: one failure is
not a fault to correct, and #117's per-Site counts plus the Series' own detail page already
show it.

Rejected: a stored column for the unverified pair. It would hold the AND of
series.latest_raised_by IS NOT NULL and a row in another table — derived data with five
writers (a Sighting raising the Series, a Poll succeeding, a Poll failing, an owner
correction, ClearSightingAttribution) for a value the join computes free. Unlike the
series-versus-table question above, this one is a genuine 3NF violation, and #116's
AdminSeries acquires the join for other reasons anyway.

What the pages render

One marker on the fact line of #122's two-line row, <outcome word> · failing <age>, in
--danger (#122's colour for trouble; never --ember), and the same line on the Series
detail page. Under-12h and over-12h rows look identical — the threshold decides list
membership only. A second visual state would ask the owner to learn a rule the page cannot
state in a word, and the age is already on the line for anyone who wants to judge it. The
unverified case adds one sentence on the detail page beside the correction control.

The per-Site counts stay unlinked — superseding #117 and #118

#117 pushed "the outcome counts should link somewhere, and that somewhere is a failing
filter"; #118 wrote it as "all five point at ?site=…&filter=failing" once this ticket
landed. That promise is withdrawn. #118's own rule — a figure links to what it counts —
is the reason, and #118 already applied it once by refusing to link the no_chapter count to
the no-chapter filter.

Two failures of the same test. refused and unreachable now have no per-Series record at
all, so two of the six counts would open an empty list beside a large number. And the other
four count attempts inside a 12h window while the filter lists Series failing now for over
12h
: a Series that broke at 04:00 and healed at 05:00 is counted and not listed, while a
Series broken for three months on a paused Lane is listed and counted zero. Neither set
contains the other, in either direction.

Instead, each Site row on the Lanes page carries one separate link to
/admin/series?site=<site>&filter=failing, as navigation rather than attached to a number.
The owner still gets from "this Site looks unwell" to "these Series are broken" in one click.
Rejected: a ?outcome= parameter to make the links exact — #118 fixed one filter at a time
with only ?site=/?kind= stacking, and a second axis would not fix the window mismatch.

Explicit non-goal

DueForLatestCheck does not join poll_failures. A failing Series is polled at the same
pace as any other; slowing a Lane for a persistent failure is Poll Lane pacing, not a
dashboard question, and nothing on this map needs it.

Amendments pushed out

  • #116 — AdminSeries gains LEFT JOIN poll_failures f USING (site, series_id);
    SeriesRow gains the outcome word and failing_since. Filter vocabulary is now eleven.
  • #117 — poll_passes gains a not_found count; the link push is withdrawn (above).
  • #118 — failing is the ninth name as reserved, unverified the eleventh; the link
    promise is withdrawn; <select> (#122) carries both new options with counts.
  • #122 — the fact-line failure marker, the Lanes-page per-Site navigation link, and the
    detail-page unverified sentence.
  • #130 — a one-write-for-both merge with this ticket's write is not available: this
    write targets poll_failures, keyed on failure, while a completion hint is a fact about a
    successful read of series. Two writes, or #130 finds its own home.

Glossary: no new domain noun — failing and unverified are filter names over facts
CONTEXT.md already defines. An ADR is worth writing when this is implemented (a table whose
row is the failure state, and two outcomes that deliberately write nothing), not now.

## Resolution One table, `poll_failures`, holding a row **only while a Series is failing** — no columns on `series`, no counter, no history. Two new filter names, `failing` (ninth) and `unverified` (eleventh). The per-Site outcome counts stay unlinked for ever. ### Migration 0017 ```sql CREATE TABLE poll_failures ( site text NOT NULL, series_id text NOT NULL, outcome text NOT NULL, failing_since bigint NOT NULL, -- unix ms, first failure of the current run PRIMARY KEY (site, series_id), FOREIGN KEY (site, series_id) REFERENCES series (site, series_id) ON DELETE CASCADE ); ``` **The row's existence is the state.** A row means "the last Poll that learned anything about this Series failed"; no row means it succeeded, or the Series was never polled (`latest_checked_at = 0` separates that), or every Poll so far ended in an outcome the write rule below ignores. So there is no `''` success sentinel, no `CHECK` keeping two columns consistent, and `failing_since` is never zero. 3NF was checked against both designs and **does not decide between them**: the key is `(site, series_id)` either way, the relation is 1:1, and the one rule between the attributes (a word implies an age) yields no value, so it is a constraint and not a transitive dependency — both designs are in BCNF. A 1:1 table with the same key is vertical partitioning, not normalisation, and a lookup table for the outcome vocabulary would be a domain constraint a Go constant already enforces. The table was chosen on its own merits: `series` stays narrow, the sentinel disappears, and the guarded write gets *simpler*. The cascade is #125's requirement and reaches this table only — never `bookmarks`, whose refusal is the orphan guard, so `DELETE FROM series` stays one statement. ### The write: one statement per Poll, a row written only on a change ```sql -- failure INSERT INTO poll_failures (site, series_id, outcome, failing_since) VALUES ($1,$2,$3,$4) ON CONFLICT (site, series_id) DO UPDATE SET outcome = excluded.outcome WHERE poll_failures.outcome <> excluded.outcome; -- success DELETE FROM poll_failures WHERE site = $1 AND series_id = $2; ``` `failing_since` survives a change of word because `DO UPDATE` never touches it — the age is the age of the *run* of failures, not of the current word. The same failure repeating writes nothing; a healthy Series polling correctly writes nothing. The extra cost lands on transitions, not on Polls. This is why no `last_outcome_at` column exists: it would sit within seconds of `latest_checked_at`, which is already stored. Rejected: three columns on `series` (`last_poll_outcome`, `failing_since`, and a consecutive-failure counter). The counter goes because it is a proxy for duration measured in units that differ per Site — each Lane carries its own `Rest`, and a paused Lane stops the count without stopping the failure. `failing since <age>` is the fact the owner reads. ### Which outcomes write, and which are silent The five-word taxonomy #117 fixed is reused verbatim, plus one split (below). But **only outcomes that are evidence about *this Series* touch the table**: | outcome | writes? | why | |---|---|---| | `no_chapter` | yes | HTTP 200, real HTML, `latestChapterFrom` found nothing (read.go:70, poller.go:458) — the Series' page or our adapter | | `unfetchable` | yes | the stored `series_url` failed `fetchableSeriesURL` (read.go:44) — the Series' own address | | `not_found` | yes | 4xx other than 403 — the page is gone | | `errors` | yes | transport failure, 5xx, or a failed `MarkLatestChecked` | | `refused` | **no statement at all** | `errChallengeHeld`: 403 or an interstitial body (read.go:55-69) — the *Site's* mood, identical for every Series it hosts | | `unreachable` | **no statement at all** | `errBrowserInterrupted` — *our own* sidecar, and nothing was read | Neither insert nor delete for those two, and the distinction is load-bearing in both directions. Writing a row would mark a whole library as failing when one Site refused for a day. Deleting one would claim recovery when nothing was read — resetting `failing_since` to zero for a Series broken three months, so a single refusal from its Site would erase the age that makes it findable. A `refused`/`unreachable` Poll therefore issues **one fewer query**, and the Series keeps the last thing it truly learned about itself. Both facts are already recorded where they belong: per Site in #117's `poll_lanes.refuse_until`, and in the poller's in-memory `browserDownAt`. **`errors` is split**: `not_found` becomes the sixth outcome word, for a 4xx status other than 403. Today one word covers a 404 (the page is gone — correct the address), a 503 (the Site is busy — wait) and our own write failing (the database is unwell): three different owner actions behind the one word the detail page prints. One branch in read.go, and #117's `poll_passes` gains a sixth count column — free on paper, since nothing on this map is built. Not written by anything else, ever: a Forced Poll (#119) **request** clears nothing, because a request is not evidence; a forced Poll that reads the page clears the row exactly as any other correct read does, which needs no special case in the code. A hand correction (#121) clears nothing, as #121 required. ### The two filter names - **`failing`** (ninth): the joined row exists `AND s.latest_chapter_num IS NOT NULL AND` `f.failing_since` older than `ownerWindow` (12h, #117's constant — no new figure). `latest_chapter_num IS NOT NULL` is what keeps it disjoint from #118's `no-chapter` (never once succeeded) rather than overlapping it for no gain. Per #124 there is deliberately **no** `finished_at = 0` guard: a Series already failing when the owner finished it keeps its evidence. - **`unverified`** (eleventh, ordered beside `sighting-raised`): `s.latest_raised_by IS NOT NULL` plus the `failing` test — the Latest Chapter came from a Reader's Sighting and no Poll has confirmed it for over 12 hours. #121's correction control on the detail page is the action it leads to. A Series failing for under 12 hours is in neither filter. That is deliberate: one failure is not a fault to correct, and #117's per-Site counts plus the Series' own detail page already show it. **Rejected: a stored column for the `unverified` pair.** It would hold the `AND` of `series.latest_raised_by IS NOT NULL` and a row in another table — derived data with five writers (a Sighting raising the Series, a Poll succeeding, a Poll failing, an owner correction, `ClearSightingAttribution`) for a value the join computes free. Unlike the `series`-versus-table question above, *this* one is a genuine 3NF violation, and #116's `AdminSeries` acquires the join for other reasons anyway. ### What the pages render One marker on the fact line of #122's two-line row, `<outcome word> · failing <age>`, in `--danger` (#122's colour for trouble; never `--ember`), and the same line on the Series detail page. **Under-12h and over-12h rows look identical** — the threshold decides list *membership* only. A second visual state would ask the owner to learn a rule the page cannot state in a word, and the age is already on the line for anyone who wants to judge it. The `unverified` case adds one sentence on the detail page beside the correction control. ### The per-Site counts stay unlinked — superseding #117 and #118 #117 pushed "the outcome counts should link somewhere, and that somewhere is a `failing` filter"; #118 wrote it as "all five point at `?site=…&filter=failing`" once this ticket landed. **That promise is withdrawn.** #118's own rule — a figure links to what it counts — is the reason, and #118 already applied it once by refusing to link the `no_chapter` count to the `no-chapter` filter. Two failures of the same test. `refused` and `unreachable` now have no per-Series record at all, so two of the six counts would open an empty list beside a large number. And the other four count *attempts inside a 12h window* while the filter lists *Series failing now for over 12h*: a Series that broke at 04:00 and healed at 05:00 is counted and not listed, while a Series broken for three months on a paused Lane is listed and counted zero. Neither set contains the other, in either direction. Instead, each Site row on the Lanes page carries **one separate link** to `/admin/series?site=<site>&filter=failing`, as navigation rather than attached to a number. The owner still gets from "this Site looks unwell" to "these Series are broken" in one click. Rejected: a `?outcome=` parameter to make the links exact — #118 fixed one filter at a time with only `?site=`/`?kind=` stacking, and a second axis would not fix the window mismatch. ### Explicit non-goal `DueForLatestCheck` does **not** join `poll_failures`. A failing Series is polled at the same pace as any other; slowing a Lane for a persistent failure is Poll Lane pacing, not a dashboard question, and nothing on this map needs it. ### Amendments pushed out - **#116** — `AdminSeries` gains `LEFT JOIN poll_failures f USING (site, series_id)`; `SeriesRow` gains the outcome word and `failing_since`. Filter vocabulary is now eleven. - **#117** — `poll_passes` gains a `not_found` count; the link push is withdrawn (above). - **#118** — `failing` is the ninth name as reserved, `unverified` the eleventh; the link promise is withdrawn; `<select>` (#122) carries both new options with counts. - **#122** — the fact-line failure marker, the Lanes-page per-Site navigation link, and the detail-page `unverified` sentence. - **#130** — a one-write-for-both merge with this ticket's write is **not** available: this write targets `poll_failures`, keyed on failure, while a completion hint is a fact about a *successful* read of `series`. Two writes, or #130 finds its own home. Glossary: no new domain noun — `failing` and `unverified` are filter names over facts CONTEXT.md already defines. An ADR is worth writing when this is implemented (a table whose row *is* the failure state, and two outcomes that deliberately write nothing), not now.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sulthan/mangaBookmark#127