Map: a proper owner admin dashboard #114
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?
Destination
A spec for a proper owner admin dashboard — locked decisions plus enough written detail
that later sessions can implement it without re-deciding anything. The dashboard's job, in
priority order: observability (is the poller healthy, what happened while I was away),
intervention (act on one Series or Reader), library-wide hygiene (find broken rows
across all Readers). Not built here; charted and specified here.
Notes
Domain: the self-hosted Go backend for BookmarkManager (manga + novel read-progress tracking),
its owner-only web surface, and the Poll Lanes that keep Latest Chapter current.
Skills every session on this map should consult:
/grillingand/domain-modelingby default;/prototypefor the visual-surface ticket;/researchfor research tickets.Standing decisions already settled while charting (do not relitigate):
requireOwner(backend/internal/web/admin.go) stays the whole access model— no roles, no second admin.
BookmarkManager Web UI, not a second visual language. One narrow 760px prose column cannotcarry an operations view, but cards/corners/shadows and a rival token set stay out.
which Reader reads what, or anyone's progress list.
DueForLatestCheck(store.go) alreadycomputes a
readerCountthis way — same shape.page reads Lane state from the database too (#117 persists it and deletes
Poller.laneStates,LaneStatus()andLaneReporter), so the whole surface stays testablewith no poller running — a test inserts a row rather than constructing a fake. A forced action
is a flag the Lane notices on its next pass, and the UI promises exactly that rather than
pretending to be synchronous.
able to tell at a glance whether to keep reading is most of what "proper dashboard" means.
Facts established while charting (2026-08-17, from the code):
/adminis two blocks:templates/lanes.html(per-Site Lane status, htmx 30s refresh)and
templates/readers.html(roster, revoke sessions, clear marks).Poller.laneStatesand is loston restart; the only per-poll DB writes are
MarkLatestChecked,SetLatestChapter,RecordSighting*,SetSeriesCover. There is no per-Series failure counter column.store.List/Getare per-readerIDonly;DueForLatestCheckis the sole cross-reader join. A Series list view needs a new read model.SetSeriesCoveronly fills an emptycover_address, so a cover refetch needs a force pathin the store, not merely a new caller.
seriescarrieslatest_sighted_atandlatest_raised_by(FK toreaders) — per-Readerattribution already sits on a shared row.
Tickets
Decisions so far
Research: how far htmx and the existing filter.js carry a filterable Series list — nothing new needed: vendored htmx 2.0.4 covers URL-addressable filters (
hx-push-url), debounced search, load-more/numbered paging, per-row swaps and confirm-gating. Butstatic/filter.jsis a client-side title filter, not URL-addressable, and is not loaded on/admin— so the Series list filters server-side. Note on branchresearch/htmx-series-list.Admin dashboard information architecture and route map — four pages behind one nav row (
/adminlanding,/admin/lanes,/admin/readers,/admin/series,/admin/series/{key}keyedsite:series_id), all joiningadminRoutes(). Split rule: landing shows library shape from the database, the Lanes page shows poller liveness from memory — no figure on both. Lanes keeps the only timer (defaultRest = time.Hour, so the new pages have nothing to re-fetch); new actions post to/admin/series/{key}/<verb>and answer with the swapped block. Landing leads with<verdict> · N series waiting · M unchecked over 24h, then a stats block whose hygiene counts link into/admin/series?filter=…. Pushes a per-Site 24h failure count onto #117 and a grouped per-Site aggregate onto #116.Cross-Series read model and the privacy boundary — two store methods (
AdminSeries(SeriesFilter) ([]SeriesRow, total, error)for rows,SeriesStats(staleBefore)for the landing aggregate) over a newSeriesRowtype that has no field forlatest_raised_by— the privacy boundary is the projection, not a template, and attribution surfaces only as an anonymousSightingRaisedbool computed in SQL. LEFT JOINbookmarks, so orphan Series (reader_count = 0) become visible instead of hidden as the Lane hides them; Reader count is a plainCOUNT, which knowingly disagrees with the Lane'sstatus <> 'finished'test until #124 removes that bucket. Seven URL-addressable filter names (no-cover,unchecked,stale,unpollable,sighting-raised,orphan, none) as compile-time predicate constants plus?site=/?kind=params;unpollablemeansseries_url = ''only. 50 rows a page withCOUNT(*) OVER ()as the total and an empty-page re-read at page 1. Stale is 12h, superseding the 24h figure in #115. Indexseries (latest_checked_at)in migration 0012, stated as a judgement to re-time.Poller command seam: force-poll and pause-lane through the database — two pieces of database state, no command channel. Forced Poll is
series.force_poll_at(migration 0013), pending derived asforce_poll_at > latest_checked_atand self-clearing oncheckOne's stamp-before-fetch, with no expiry — an ageing request is the evidence a Lane is stuck. It overrides the waiting rules (rest cutoff, Sighting deferral, finished-only bucket) and wakes a sleeping Chrome, never the refusal backoff, the gap, an emptyseries_urlor the bookmarks join. Paused Lane is apoll_lanes(site, paused_until)row with a mandatory expiry (1h/6h/24h), read at the top ofrunLanePass, surviving restarts; no global runtime pause —LATEST_CHAPTER_POLL_ENABLEDplus a redeploy stays that. Acquisition is untouched by a pause. UI: Check now on both the list row and the detail page from one fragment, unconfirmed,check requested <age> ago, no ETA; the Lanes page reads pause from the database, since memory is empty after a restart while a pause is not. Pushesforce_poll_atonto #116's projection, two controls onto #122, and the Sighting-remedy wording onto #121.Poll history: persist it, or stay a live-now view — one row per Lane Pass, append-only, ~120 rows/day:
poll_passes(migration 0014) carryingdue/checked/gap_ms/clamped, a nine-valueskipenum (one perrunLanePassreturn path, sodue > 0 AND checked = 0 AND skip = ''is the only true stall), and five named outcome counts (refused/unreachable/no_chapter/unfetchable/errors) mapped from the classificationreadSeriesPagealready makes; success is derived, never stored, and a zero renders as none observed.Poller.laneStates,LaneStatus(),LaneReporterandPoller.refuseUntilare deleted — refusal becomes durablepoll_lanes.refuse_until(the Site's mood outlives our process) whilebrowserDownAtstays in memory (our own reach does not), and sidecar reachability is derived fromunreachableinsidelatest.RefuseBackoff. Retention is delete-on-insert, 14 days, pruned by the Lane goroutine — notime.Tickerenters a backend that has none. OneownerWindow = 12 * time.Hourfeeds both the staleness cutoff and the outcome window, retiring #116'sstaleAfter. #115's split rule is void (its premise was the deleted memory) and becomes landing aggregates over Sites, Lanes page shows per-Lane detail. Amends #119:poll_lanesbecomes the per-Site state row, soResumeLanezeroespaused_untilinstead of deleting. Audit trail not subsumed; the per-Series hole is #127.Hygiene filters for the Series view — the filter vocabulary closes at eight: #116's seven plus
no-chapter(latest_chapter_num IS NULL AND latest_checked_at > 0 AND series_url <> ''), a Series the poller has attempted and never once read a chapter from — durable today, no column, no write on the poll path, and not subsumed by #127'sfailing(never worked vs worked for a year and broke on Tuesday). A "Latest Chapter went backwards" filter is rejected: nothing stores the previous value, and a downward write is the correction (poller_test.go:481-497asserts 400 -> 296), so it would flag the Series that just healed while a stable wrong number never fires it at all;sighting-raisedstays the only suspicion lens and #121 the repair. Presentation: one display label per filter, ordered permanent-and-fixable first; every figure in the landing stats block links to what it counts (total, kind split, Site label, roster — not just hygiene counts) except a zero, which prints the digit unlinked rather than opening an empty list; one filter at a time with?site=/?kind=stacking, heading<N> series · <label>, and an empty state naming the filter it is empty for. No aggregate problem count — the classes overlap, so a sum over-reports. #117's five per-Site outcome counts stay unlinked (the pass row holds counts, never identities) until #127, then all five point at?site=…&filter=failing. Row field layout and density stay #122's call.Per-Series cover refetch and the store's force path — the owner replaces a Cover by asking for a Forced Poll: no second column, no dedicated control, no synchronous fetch, and
checkOneswapsfillBlankCoverfor a replace when the pass was forced (an unforced Poll still only fills a blank). New cover writes are addressed by the SHA-256 of the bytes while legacy URL-hashed rows are left untouched — legal with no migration becausecoverAddressReis the only contract on the value, and a rehash could not be one anyway (migrations are SQL-only). That makes a re-art behind an unchanged URL visible and makes identical art an honest no-op the page can report, instead of the double silent no-opos.Link+ON CONFLICT DO NOTHINGproduce today;CoverAddress's name-it-before-the-bytes property has no production caller, andprefetchCover'sGetCovershortcut is deleted rather than kept with acovers.source_urlcolumn. Store surface is a second method ReplaceSeriesCover writingcoveras well ascover_address(aforce boolrejected: three callers would passfalsefor ever). No byte deletion here — reclamation for both causes goes to #125. Fetch reusesfetchCoverBytes, so kagane and comix covers keep the sidecar and a missing sidecar merely ages the mark; the Lanes page already states the sidecar fact, so no new notification. Unconfirmed, no--danger, never--ember: a Cover that disagrees with the Site is the confusing state, not the change.Data-correction actions: Latest Chapter and series_url repair — the motivating case was not a defect: #79 settled that the chapter-list maximum is Latest Chapter and forbids capping it, so "fixes the class of #79" is struck and the surviving class is a Reader-raised value on a Series the Poll cannot read (#119's Forced Poll covers every readable one). A hand-set value is therefore not authoritative — no pin, no floor: it is overwritten by
checkOneand by any Reader's PUT (store.go:864-867last-write-wins,reportLatestChaptersends the site-read number up or down on every series-page visit), and being overwritten by a page read is correct because the Poll is the oracle. One numeric input, label derived asChapter <num>(a free-text label can disagree with the number the ember compares).series_urlrepair is owner-typed only, validated by the existing gate renamed tolatest.FetchableSeriesURL— Reader-PUT healing rejected becauseseriesis a shared row and the gate stops SSRF, not mis-pointing; a Site-wide host change stays a migration. Newlatest_corrected_at(migration 0015) means "the current value is a human's", zeroed bySetLatestChapterand by the Upsert only when the number actually changes — deliberately not an audit trail. A contradicting correction callsClearSightingAttribution, neverRecordSightingOutcome(a mark needs 20 confirming Polls that this class will never get). Detail page only, unconfirmed, silent to Readers; pushes onto #127 that a correction must not clear a failure counter, and nothing onto #116/#118.Prototype: the Cinder admin surface — the artifact is the Claude Design project BookmarkManager Web UI (
969ac210-fe02-4c01-ae1b-9a271dcc779a), filesadmin.css+admin-{overview,lanes,readers,series,series-detail}.html; branchprototype/admin-surface-122is superseded exploration, not the thing to port. One new token,--measure-wide: 1080px— admin only,--measure: 760pxunchanged for reading pages. Row shape is aligned borderless columns: one CSS grid per table, hairline header and row rules, no vertical rules or cards, gutters as cell padding, tabular mono figures in mixed case; the Series list is the subgrid two-line form (title line over fact line) separated by banding (--hover) rather than hairlines, Lanes and the per-Site table single-line. Actions are right-aligned 12px ghosts: Check now unconfirmed with itsrequested <age> agomarker on the title line, destruction via.ghost.dangeropening an in-place.confirm-rowon--danger-wash. No ember anywhere on admin;--patinais the accent,--dangerfor lane trouble and destruction, a deliberate pause stays patina. Also fixed: nav row of four display-face tabs,.secheads led by a patina tick instead of a rule, verdict line in the mono data face, stats as an auto-fit grid with zeros unlinked, the landing per-Site table carrying library shape only (#117's five outcome sums stay on Lanes), and the phone breakpoints (Lanes ≤1019px, everything else ≤899px), which retires the phone entry from Not yet specified. Amends #118: the eight hygiene filters are a<select>with counts in the option labels, not a chip row — one-at-a-time and URL-addressable as specified, only the control changed.Series-level finished state, and the fate of the finished Lifecycle bucket — finished moves from the Bookmark to the Series and the Reader vote is deleted, recorded as ADR-0012 (
docs/adr/0012-finished-belongs-to-the-series.md). New columnseries.finished_at bigint NOT NULL DEFAULT 0(migration 0016, epoch-ms, zero means never, owner-written only — no adapter, Reader or Poll may set it), so both Lane queries dropHAVING COUNT(*) FILTER (WHERE b.status <> 'finished') > 0:DueForLatestCheckgainsWHERE (s.finished_at = 0 OR s.force_poll_at > s.latest_checked_at)andEligibleSeriesCountgainss.finished_at = 0with deliberately no force clause, so one press cannot speed up the Lane's pace. That kills the aggregated-per-Reader-votes bug outright. ThefinishedLifecycle bucket is removed (Lifecycle bucketis now two states; 0016 flips surviving rows toarchivedbefore seeding the column, order load-bearing), and Readers instead get a read-only derivedfinished boolon the wire — purely a label, discarded inbound byUpsert's explicit column list exactly ascoveris. A Forced Poll overrides a finish for one pass and never clears it; un-finishing is an explicit owner action. Control lives on the Series detail page only, confirm-gated to finish and instant to un-finish,--patinanot--danger.finishedbecomes the tenth filter name (ordered last, informational), andstale/unchecked/no-cover/no-chaptereach gainAND s.finished_at = 0so a finished Series never ages into a hygiene figure. A Site's own "completed" marker may never write the column — it may only hint, split out as #129 and #130.Research: which Sites publish a machine-readable completion marker — all six publish one, and a hint keyed on the completed value has no false-positive path on any Site (measured 2026-08-19; note on branch
research/completion-markers, filedocs/research/completion-markers.md). Four Sites carry the value in a payload the adapter already fetches — asurascans in its astro-islandstatus:[0,"…"]props, comix in theinitial-datadetail blob, kagane in the API body'spublication_status+upload_status, lightnovelworld in JSON-LDcreativeWorkStatus— so extraction is cheap; demonicscans (Status<li>pair) and novelfull (/status/<value>link) need one selector each. Vocabularies split three ways: kagane is the only Site separating the work's status from the translation's (proven divergent on one series), asurascans'dropped/hiatusand kagane'supload_statusare scanlation-editorial state rather than completion, and demonicscans + novelfull are binary (Ongoing/Completed), so a stalled series there reads Ongoing for ever. The residual risk is therefore false negatives only, which inverts #130's containment problem.Orphan Series: is removal an owner action, and what happens to the Cover bytes — yes, one at a time, orphans only: Remove on the
/admin/seriesrow and on the detail page, rendered solely atreader_count = 0, withbookmarks_series_fk(no cascade) as the entire guard — a plainDELETE FROM serieswhose FK violation means "a Reader bookmarked it again"; no bulk sweep, and the delete is no one-way door sinceUpsertrecreates the row. Amends #122: Check now leaves the orphan row (a Forced Poll never overrides thebookmarksjoin, so the request would age for ever and fire #119's stuck-Lane signal), and the confirm copy stops promising byte deletion. Cover reclamation is one guarded helper called at both causes (removal and #120's replace): delete thecoversrow onlyWHERE NOT EXISTSa Series pointing at it — no sweep, no schedule, no retry surface. Order inverted from the obvious one: unlink first,coversrow last, because the row is the handle — an interrupted reclamation is then one SQL query over unreferencedcoversrows instead of a filesystem walk, and #120's "a non-blankcover_addressis unrepairable" is superseded by its own forced replace path (one Check now re-links the missing file). PushesON DELETE CASCADEfor any per-Series table onto #127.Per-Series Poll outcome state and the failing filter
— one table,
poll_failures(migration 0017), whose row existence is the state: a row means thelast Poll that learned anything about this Series failed, and a correct read deletes it, so
there is no
''success sentinel, noCHECK, and no counter. Keyed(site, series_id)REFERENCES series ON DELETE CASCADEper #125,seriesuntouched. 3NF was checked and does notdecide columns-vs-table (same key, 1:1, both BCNF); the table won on the sentinel, the narrower
series, and a simpler guarded write —ON CONFLICT DO UPDATE … WHERE outcome <> excluded.outcomekeeps
failing_sinceacross a change of word, so the same failure repeating and a healthy Poll bothwrite nothing. Only Series-evidence writes:
no_chapter,unfetchable,not_found,errors;a
refusedorunreachablePoll issues no statement at all — writing would mark a whole libraryas failing when one Site refused, deleting would claim recovery when nothing was read and reset the
age that makes a three-month failure findable.
not_foundsplits out oferrors(4xx other than403), making #117's taxonomy six words. Two filter names:
failing(ninth) = row existsAND latest_chapter_num IS NOT NULL AND failing_sinceolder thanownerWindow(12h, no new constant) —the
latest_chapter_numtest is what keeps it disjoint from #118'sno-chapter, and per #124 thereis no
finished_atguard;unverified(eleventh) =latest_raised_by IS NOT NULLplus thefailingtest, the pair rejected as a stored column because it would be derived data in two tableswith five writers. Under-12h and over-12h rows render identically (
<word> · failing <age>in--danger); the threshold decides list membership only. #117's and #118's promise that theper-Site outcome counts would link to
?filter=failingis withdrawn — two counts now have noper-Series record and the other four count attempts-in-12h rather than currently-failing Series, so
each Lanes-page Site row gets one separate navigation link instead. Non-goal:
DueForLatestCheckdoes not join the table, so a failing Series is polled at the same pace.
Completion-marker hint: storage, presentation, and false-positive containment #130
— the containment machinery the title asked for is not built: #129 removed the false-positive
path (read only the completed value and no Site can lie about completion), so there is no
N-consecutive-observation gate, no expiry and no confirmation counter, and the residual error is a
false negative we accept. Storage is one column,
series.site_completed_at(epoch-ms of theread that saw the Site's completed value, zero means the last successful read did not) — decay comes
free, since every successful read rewrites it; a normalised status word was rejected because the
vocabularies are not comparable (demonic and novelfull binary, only kagane splits
publication_statusfrom
upload_status). One per-Site predicate reads the completed value only — asuracompleted,demonic
Completed, comixfinished, kaganepublication_statusonly, novelfullCompleted,lnw
CompletedActionStatus; a failed extraction means not-completed, and per #127 a refused orunreachable Poll makes no statement, keeping the previous value. All six adapters read it,
including demonic's and novelfull's new selectors, because on those two Sites
Completedis the onlysignal that exists and a broken selector degrades to a missing hint. The write is its own statement in
checkOneafter the read (#127 killed the shared write) and fires only on a transition —site_completed_atjoins the due-query projection, so the ordinary ongoing Poll writes nothing.Presentation is the twelfth filter name
site-completedplus one landing stats figure linking toit (zero unlinked per #118) plus one line on the detail page; no list-row field (#122 gives a row
two lines). The Finish control is unchanged and still confirm-gated — the hint is text beside it,
never a shortened path, or the Site would decide the Lifecycle. No dismissal column. The filter
carries
AND finished_at = 0and the detail page hides the hint on a finished Series, whose value#124 freezes by removing it from the Poll query.
Latest Chapter provenance: what records that the number moved
— nothing does, and nothing will: no history table, no attribution column, no migration 0018.
The actor behind the present number is derived from state already decided, in three classes:
Correction (
latest_corrected_at <> 0), Sighting (latest_raised_by IS NOT NULL), machine read(neither, with
latest_checked_at > 0). Acquisition does not survive as a distinct actor —acquire.go:125stampslatest_checked_atexactly aspoller.go:428does, and the onlyactionable case is already the
uncheckedfilter. This promotes #121'slatest_corrected_atfrom convenience to load-bearing (the sole evidence a Correction happened, kept honest by its
zeroing rules), so it must not be dropped. Presentation is one line on the Series detail page
naming the actor class: no list-row field, no thirteenth filter name, no landing figure. The
Cover half is closed by construction (#120's SHA-256 address, #125's reclamation) and a
superseded Cover is deliberately unrecoverable; #118's "went backwards" filter stays rejected
on its original ground, since nothing stores the previous number. A history table was designed
before being dropped (one row per real change, actor class only, newest-20-per-Series pruned on
insert, migration 0018) and died on three costs: a per-Series timestamped table is one column
away from the per-Reader reading log the map rules out of scope; it would duplicate "the
current value is a human's" with a second writer, which #127 rejected for this exact reason; and
no owner question needs the past, because the Poll is the oracle and
sighting_disagreementsalready carries distrust. Glossary gains Correction; Movement deliberately not added.
Notification outside the page: is the dashboard pull-only? — not pull-only: four conditions push one Discord embed each, and the rule that selects them is the decision — silent on every Reader surface, wrong, repairable only by the owner, and holding past
ownerWindow(12h).stall(#117's test amended withAND refused = 0, because therefusals >= 2break at poller.go:301-306 is not an early return and so read as a stall, double-firing on a newly-gated Site),no-browser-route(a Site with no browser route refusing >12h — the comix 2026-08-12 event, repaired by a deploy; a browser Site refusing sends nothing, per ADR-0010),sidecar-down(>12h — invisible to the stall test, since poller.go:234/245 return skip values, so "asleep an hour" and "gone a week" look alike),adapter-broken(over half of a Site's Series holding a #127no_chapterrow >12h — a share so a 4-Series and a 200-Series Site both fire). Never: a browser Site's refusal, a pause, one failing Series, an ageing forced Poll, a gated cover host. Fires in the deferredpoll_passesinsert — the only point every return path crosses — and needs no timer, sincePoller.lanealready wakes at least hourly (the ticket's "no clock" premise was wrong). Suppression is newowner_notices(kind, site, notified_at), one row per episode cleared when the condition lifts;siteis the empty string forsidecar-downso three browser Lanes send one message, the row's presence being the lock — which is why it is a table, not columns onpoll_lanes. Thresholds stay derived frompoll_passesandfailing_since; the only new durable fact is that the owner was told.DISCORD_WEBHOOK_URL, unset means off (asBROWSER_WS_URL), never logged, and it touches no part of the OAuth login path; a failed POST logs and leavesnotified_atunset, so the next pass retries — no queue. Presentation is an embed, colour13589581(dark--danger), title carrying thePUBLIC_BASE_URLdeep link, one-sentence description, Discord timestamp, machine word in the footer, no field grid;--emberstays forbidden. Test seam isPoller.Notifier, nil meaning off. Amends #115 and #122: the landing verdict line must read the same four conditions. Rejected transports: Telegram (a second identity), ntfy.sh public (topics are public by design, 250/day), self-hosted ntfy/Gotify (the infrastructure this refuses).CONTEXT.mdgains Stall; no ADR (reversible by deletion).Not yet specified
Empty — the way to the destination is charted; nothing in scope remains unspecified.
Out of scope
Multiple admins, a role column, or per-user admin grants — the single-owner model is the
destination's boundary.
Inspecting an individual Reader's library or reading progress. Ruled out by the privacy
boundary; only anonymous counts cross into admin views.
Metrics, charts, or a timeseries store. A swapless 1974 MiB VPS running Postgres argues
against it, and no decision on this map needs a graph.
A monitor outside the backend (an external prober against
/healthz, Uptime Kuma, healthchecks.io). A push cannot report its own process's death, so the honest coverage lives outside — but it is another service to run, watch and update, and it watches a fault that is already visible from the Reader surfaces: the web UI dies visibly, and the userscript degrades to its cache with⟳ N pendingin the panel (manga-bookmark.user.js:1313-1314), losing no Progress. Downtime therefore costs a delay and is found within one reading session. Ruled out while resolving #132.Bulk rewrite of
series_urlacross a whole Site. A host change (asuracomic -> asurascans)invalidates every row of a Site at once, and Data-correction actions: Latest Chapter and series_url repair
already ruled that case a SQL migration rather than a dashboard control — the dashboard's
library-wide job is finding broken rows, not batch-repairing them. Was a fog line; it is a
scope boundary, not an undiscovered decision, so it never graduates.
Charted. The map's destination is reached: four specs, all labelled
ready-for-agent, in dependency order.LaneReporter), the filterable Series list, the Series detail page, force-poll and pause through the database. Blocked by nothing.series_urlrepair behindfetchableSeriesURL, forced Cover replace, orphan Series removal. Blocked by #134.series.finished_atowns the poll gate, the Reader-facing Lifecyclefinishedbucket is deleted, one-way migration seeded from today's buckets. Blocked by #134.poll_failures(the row's existence is the failure state), thefailing/unverifiedfilters, the six-Site completion hint, and the four-condition Discord notification. Blocked by #134, #135, #136.Seams confirmed with the owner before writing: the router over a real store is the primary seam for everything the owner sees; the poller's round-at-a-time entry point for everything a Lane writes; the store and wire seams unchanged. Seam count goes down —
web.LaneReporterand its fake die with the pass log. Exactly one seam is added,Poller.Notifierwithnilmeaning off, matching the existing fetcher field injection.Split at four rather than one, on the owner's call: each spec is independently shippable and none is a monolith no agent finishes in one pass.