Scope: `backend/`. Each entry names the code that holds the truth — read that for *what it does*. The prose here is only what code cannot tell you: rationale, rejected alternatives, dated measurements, and invariants a plausible refactor would silently break. ### Layout `backend/main.go` → `newRouter` is the composition root, the only place packages are wired. Packages under `backend/internal/`: `store`, `latest`, `session`, `httpmw`, `api`, `userscript`, `web`, `token`, `pgtest`. Root-level `*_test.go` exercise the full router; unit tests live beside their package. Not visible from any single file: stdlib `net/http` with no framework, Postgres over `jackc/pgx/v5`, `CGO_ENABLED=0` static binary into a distroless image, TLS terminated by the reverse proxy so the service listens plain `:8080`. ### Schema — `internal/store/migrations/*.sql`, run by `store.migrate` - Migration files are **append-only**. Editing an applied one changes nothing on a database that already recorded its version in `schema_migrations`, so the fix silently applies to new deployments only. - No column probing, no data-fixup migrations. Both were SQLite-era machinery and were removed deliberately — don't reintroduce either. ### Tests need Docker — `internal/pgtest` `pgtest.Main` from `TestMain` starts one `postgres:17-alpine` per test binary; `pgtest.URL` hands each test its own database. A package whose tests touch the store must have that `TestMain` or it has no database at all. ### Reader-owned store — `internal/store`, `internal/token` - The Reader-owned tables are `readers`, `bookmarks`, `series`, and `sessions`; auxiliary `covers`, `poll_lanes`, and `poll_passes` are also defined in the migrations. - **Credentials are derived, never stored.** `token.Token(TOKEN_KEY, discord_id, epoch)` is an HMAC; only its SHA-256 reaches `readers.token_sha256`. So install URLs can be rebuilt after any restart, and a database leak yields nothing usable. - **The owner's epoch-0 hash is refreshed at startup only while the row has never been rotated.** Drop that condition and a restart resurrects a rotated-away credential. - `Store.EnsureReader` never rewrites an existing row's hash — a returning Reader's login must not invalidate their installed scripts. - **Every read and write is scoped to the acting Reader**, resolved from the presented credential by `httpmw.Auth` and carried in the request context. There is no unauthenticated-by-Reader route and no global token. - **`series` holds what readers share, `bookmarks` only what differs.** A bookmark key is `(reader_id, site, series_id)` with no surrogate id; the wire `key` is derived as `site:series_id` on read. - `Store.Upsert` splits one flat body across both tables and enforces the ownership rule: client `title`/`series_url`/`cover` are written **only when the series row is new**, so one reader cannot retitle a shared series. - Sync is last-write-wins and the wire format stays flat — clients depend on both; neither is an implementation detail to tidy up. ### Web UI — `internal/web` Routes, templates and assets are all in that package; `AdminPatterns()` and `adminRoutes()` enumerate the privileged ones. - **`backend/Dockerfile` must copy the whole `internal/` tree**, not just `*.go`: templates and static assets are `go:embed`-ed from `internal/web/`. - **Guild membership *is* registration.** `discordCallback` gates on membership (plus `DISCORD_REQUIRED_ROLE` when set) and only then calls `Store.EnsureReader`, so a refusal creates nothing. - Sessions are rows, not signatures: the cookie carries an opaque id and expiry is checked on lookup, which is what makes deleting the row an instant revocation. - UI mutations go through `Store.Get` + `Store.Upsert` so the `updated_at` rule below stays in exactly one place. - `listView.Fresh` exists because a Reader with no bookmarks at all needs install links, not an empty-filter message. - **Design-tool caveat:** `detect.mjs backend/internal/web/templates` reports a **false clean**. Templates link `/static/style.css` root-absolutely (correct — it is served from `/`), but the detector resolves hrefs with `path.resolve(fileDir, href)`, which drops the directory on a leading `/` and skips the file silently; a relative href doesn't help either, since a template's directory isn't its served path. Always pass `backend/internal/web/static` too. The one finding there, `overused-font` on "Instrument Serif", is a deliberate identity choice, not debt. ### Confirm gating — `internal/web/static/filter.js`, `toggleConfirmRow(key, kind)` Every action that pulls a series out of the list (`archive|finish|remove`) opens its own `.confirm-row`; restore fires instantly because it is the reversal. Remove wears the ember wash, the two reversible ones wear `.calm` grey. **`--ember` is reserved for the new-chapter signal** — the busy bar and inline errors must use `--mute`, or the one colour that means "something to read" stops meaning it. ### Latest-chapter poller — `internal/latest`, Site registry in `sites.go` One goroutine per Site (a Poll Lane) re-checks that Site's bookmarked series from the backend's own network position, so `latest_chapter` stays fresh while nobody is browsing. The userscript's `reportLatestChapter` is a second, parallel signal — it PUTs every read, unchanged numbers included, because an unchanged read is exactly the Sighting worth deferring a Poll on. - **Pace lives in the Site registry, not config.** Two clocks: per-series rest (`series.latest_checked_at`, enforced in `Store.DueForLatestCheck`'s WHERE) and per-Lane gap (`effectiveGap`). The five env knobs that used to size one shared pace are gone; don't add them back. - **The poller walks Series, not Bookmarks** — a series several readers hold is fetched once per cycle, and the due queue orders `reader_count DESC, latest_checked_at ASC` so the widely-read ones win contention. - **The series row is stamped *before* the fetch**, so a permanently broken series waits out its rest instead of being retried every tick. - `Store.SetLatestChapter` is a single-column UPDATE, deliberately not a read-modify-write of the bookmark: it therefore cannot revert read progress or move `updated_at`. The old stale-re-read race died with the Get+Upsert flow — don't restore one here. **Sightings** (`Store.RecordSighting`, the due query's HAVING clause, `latest.checkOne`) let a Reader's own page read defer a Poll. - Recorded by the PUT handler **before** the Upsert, because the raise test needs the row as it stands. - A Series is deferred only while it has exactly one Bookmark, was sighted within one Rest, and is under `sightingCeilingRests` since its last Poll — so a shared Series is never deferred and nothing goes six hours unpolled whatever arrives. - A *higher* report clears the attribution rather than crediting it: the value the Poll then stores is its own, so a later retraction isn't the Reader's fault. - `store.SightingDisagreementLimit` contradictions stop a Reader deferring — their reports still write the Latest Chapter — and `store.SightingAgreementsToClear` agreements forgive them, as does the owner's clear-marks control. - Deferral is recomputed from live facts each round, so nothing needs invalidating when a Series gains a second Bookmark. The one input read earlier is the Reader's marks, so crossing or clearing a threshold takes effect from their next Sighting and the standing already bought lasts out its rest. **Refusals and browser loss are Lane-local.** Two `errChallengeHeld` in a pass stop that Site for `RefuseBackoff` while other Lanes continue. An `errBrowserInterrupted` (remote Chrome restarted) sets a shared Poller flag so the *other* browser Lanes skip their passes for the same window — otherwise a restarting Chrome stamps one Series per Lane per pass, burning rests on failures. The flag decays and they probe again. - **`isInterstitial` matches the orchestration path `/cdn-cgi/challenge-platform/h/`, never the bare prefix.** Cloudflare injects `/cdn-cgi/challenge-platform/scripts/jsd/main.js` into ordinary 200 pages once a zone turns JS detections on, which demonic did on 2026-08-16: the prefix match read every real demonic page as a refusal and parked the Lane in backoff while plain TLS was returning full series pages. - Fetches use `bogdanfinn/tls-client` with a Chrome profile as defence in depth against fingerprint blocking; any failure logs and skips. - kagane, comix and novelfull sit behind Cloudflare JS challenges the TLS client can't clear, so they go over CDP (`BROWSER_WS_URL`). kagane and comix are simply not polled when it's unset — a plain fetch would only retrieve a challenge page — while novelfull still attempts plain TLS, because its challenge is a live time-varying fact and its cover bytes never need a browser. - **comix's browser read is an in-tab `fetch()` of the Series URL, not a DOM render.** It is an SPA: rendering cost ~65 requests for the same server-rendered HTML one fetch returns (measured 2026-08-12). - Browser Lanes wake Chrome only when 5+ Series are due or one has waited 15m, and cover work runs in the background so a slow CDN can't eat a Lane's gap. ### Covers — `Store.OnSeriesCreated`, `latest.Acquirer`, `latest.CoverBytesFetcher`, `Store.SetSeriesCover` Acquired once when the first Bookmark of a Series is created, then served from our own origin by the public `GET /covers/{addr}`. - Acquisition runs in a goroutine: the Reader's PUT must neither block on a Site nor fail with one. Every failure is logged and dropped, leaving the Bookmark intact. - The wire `cover` is the absolute `PUBLIC_BASE_URL + /covers/{sha256}` once bytes exist and `""` before — **never an address that 404s**. Absolute because the userscript renders it on a Site's origin. - `GET /covers/{addr}` is public and uncredentialed by design: no cookie or token of ours may travel to a Site's origin. - A client-sent `cover` is decoded and discarded, permanently — wire compatibility, not an oversight. - **One route serves all six Sites.** No proxy, no per-Site rewrite, no second place that decides a renderable address: the wire `cover` is it. Templates render `.Cover` and nothing else. The old kagane-only serving path (`/img/kagane/{id}` plus a template rewrite) is gone; don't reintroduce a per-Site route because one Site's CDN misbehaves. - The only Site names left in cover code are in `browserOnlyCoverURL` (`internal/latest`): kagane answers a plain fetch with a challenge *and* `cross-origin-resource-policy: same-origin`, and `static.comix.to` answers with the same challenge its pages serve. Every other Site's CDN answers plain TLS. - **comix cover bytes must arrive by direct navigation, not an in-page fetch:** its Series page sets `cross-origin-embedder-policy: require-corp`, which fails a page-context fetch of `static.comix.to`. - With no browser configured, kagane and comix Covers are simply absent; novelfull still gets one whenever its page answers a plain request. ### `updated_at` drives list order — `Store.Upsert` The server applies its own timestamp only when the row is new or `last_chapter_num` changes, else it keeps the stored value. **Favouriting a series, or a newly published chapter arriving, must not reorder the list** — only real reading progress moves a row. Consequently `PUT` returns the row **as stored** and clients must adopt that response rather than their own payload. ### Lifecycle buckets — `status` on each bookmark `reading` | `archived`, orthogonal to `favorite`. Archived rows appear only in their own tab, never in All, Updated, Favourites or the recent strip. The poller keeps checking archived series; a finished Series (issue #157) is a `series.finished_at` fact the Lane gate reads, with every bookmark on it archived. - `PUT /bookmarks/{key}` accepts only the two values; anything else — `finished` included — is a plain 400, and the web UI's own status control validates the same way. The 0016 migration is the only writer of the flag today; the undo is writing 0. - **An empty incoming status means "keep the stored one"**, and it is resolved on the `VALUES` side of `Store.Upsert`, not in the conflict clause: `excluded.*` is the post-evaluation row, so a default applied there would wipe the bucket on every PUT from a client predating the column. ### Config — `Config` / `loadConfig` / `loadLatestPoll` in `backend/main.go` That function is the complete list of env vars, their defaults, and which are required. What it can't tell you: - `PUBLIC_BASE_URL` must be an absolute origin because every Cover URL on the wire is built from it and the userscript renders on a Site's origin. - `BROWSER_WS_URL` **must be a tailnet IP, never a hostname** — Chrome's DevTools handler 500s `/json/version` for any Host that isn't an IP or `localhost`. Unset (the default) disables browser polling. - `USERSCRIPT_PATH` / `NOVEL_USERSCRIPT_PATH` are bindmounted files; the `__API_TOKEN__` placeholder inside them is substituted with the requesting Reader's credential at serve time. - Pace is per Site in the registry, not env. The `_COOLDOWN`/`_BROWSER_COOLDOWN`/`_INTERVAL`/`_BATCH`/`_STAGGER` knobs are gone on purpose. - The 1h rest for browser Sites is safe on documented grounds: a challenged page costs seconds of a serialized single-tab browser, free-plan zones carry no bot score and no published per-IP rate input, and `cf_clearance` expires in 30 minutes, so every cadence at or above 1h re-solves anyway. ### Userscript install & rotation — `internal/userscript`, `internal/token` Session-gated `GET /install/{manga,novel}-bookmark.user.js` renders the bindmounted script with the acting Reader's derived credential substituted in, so the credential never appears in page markup, the address bar, or a redirect. `?download=1` adds `Content-Disposition: attachment` for mobile Violentmonkey, which ignores a `.user.js` navigation. `POST /rotate-token` is an atomic epoch bump plus hash rewrite and invalidates every installed copy — the panel must keep warning to reinstall on all devices. ### Owner-only admin — `internal/web/admin.go` - **Every route reaching past the acting Reader is listed in `adminRoutes()` and wrapped in `requireOwner` at registration** — add it there, not as a check inside a handler; `web.AdminPatterns()` is what the gate test walks. A non-owner gets 404, never 403. - The one owner comparison left outside the gate is in `index` (`view.Owner = readerID == h.store.OwnerID()`): it gates a link, not an endpoint, so it is a rendering decision a registration-time wrapper cannot express. Do not "unify" it into the gate. - The Lanes page reads the pass log, never a running poller: `lanesView()` in `admin_lanes.go` projects `store.LatestLanePasses()` and `store.LanePassOutcomes()` (ADR-0012), so a restart answers the instant the database is up. Browser configuration is a config fact and reachability is derived from recent browser-Site passes inside `latest.RefuseBackoff` — no reporter interface exists to fake. - A pass that returns before computing figures (refusal backoff, sidecar down) carries the previous pass's numbers forward rather than recording zeroes. - **`Checked` next to `Due` is what separates a stopped Lane from a quiet one**, so neither may be dropped from the row. - Due-without-Checked is **not** by itself a stall: a browser Lane under both wake thresholds records its pass with the `SkipAsleep` skip and renders "browser asleep", and that never counts toward `Attention`. It is the commonest healthy state for kagane, comix and novelfull, so spending the stall mark on it would train the owner to ignore the mark that matters.