Cross-Series admin read model, with the privacy boundary in the projection #140

Closed
opened 2026-08-21 16:39:20 +07:00 by sulthan · 0 comments
Owner

Parent

Spec #134.

What to build

Every read of a Series today is scoped to one Reader, so the owner cannot ask library-wide questions: how many Series have no Cover, which ones has the poller never read a chapter from, which ones does nobody hold any more. Those rows accumulate -- removing a Bookmark leaves the Series behind and nothing ever deletes one. This ticket lands the two store reads that answer those questions, and the privacy boundary that keeps them anonymous. No page changes here; it is verified at the store seam.

A filter value object (Site, Library kind, filter name, a caller-supplied staleness cutoff, a 1-based page) feeds two methods: a row read returning rows plus the filtered total, and a per-Site aggregate. Two methods, not one -- the landing page reads only the aggregate and must not pay for fifty rows it discards. The filter vocabulary is shared by being one set of named predicate constants, not by being one method.

Eight filter names: no series URL, never read a chapter, no Readers, never checked, not checked in the window, no cover, latest from a Reader's report, and the absent/unknown case meaning all Series. Ordered permanent-and-fixable first, and labelled as the repair they need rather than as the SQL they are -- the owner arrives to act, not to admire a predicate. Two notes that are easy to get wrong: "no series URL" means an empty URL only (the host-fails-the-fetch-gate case is invisible to SQL, needs the Site registry in Go, and would break both the count and the pager if filtered after the read -- it is a repair, and belongs to a later spec); and "never read a chapter" and "never checked" are disjoint by construction (non-zero versus zero check stamp), so the two counts never double-report a row.

The row query is a LEFT JOIN to Bookmarks, not a JOIN: orphans are exactly what hygiene has to find, and the Lane's join hides them. A zero Reader count is the orphan marker, and it is an anonymous Series-level fact. Reader count is a plain count of every Bookmark on the Series, which knowingly disagrees with the two Lane queries for as long as the finished Lifecycle bucket exists -- a later spec removes that bucket, after which the definitions are the same set. Recorded rather than hidden.

The tie-break is mandatory. Every unpollable Series has a zero check stamp, so ordering on that column alone gives no stable page boundary and rows repeat or vanish across pages. 50 rows a page; the filtered total comes from a window count in the same query, because window functions run after grouping and before the limit, so one where-clause cannot disagree with a second copy of itself.

The privacy boundary is the projection, not a template. The existing Series column list carries the Reader id that raised a Sighting, so reusing it would put Reader identity in the admin handler and leave the boundary resting on a template that happens not to print a field. The admin row type has no field for it and that column never leaves the store package. A boolean answering "a Reader's report set this number" is computed in SQL instead, so the owner learns which Series to look at and nothing about which Reader.

Also lands the two schema pieces the read model and the next tickets need, as an expand step: a series (latest_checked_at) index (the table has only its primary key today -- this supports the default order and is a judgement, not a measurement, since a grouped query over a join may ignore it; re-time on real data before adding a second), and a force_poll_at column defaulting to zero, unused until the Forced Poll ticket wires it.

Acceptance criteria

  • Each of the eight filter predicates returns the rows it names and no others against seeded rows, including the orphan case only a left join can see
  • "Never read a chapter" and "never checked" are proven disjoint over a seeded mix
  • The paging tie-break holds with several rows sharing a zero check stamp: no row repeats or vanishes across pages
  • The filtered total agrees with the row count for the same filter, and the aggregate agrees with the row query for the same filter
  • A dedicated test asserts the admin column constant does not mention the Sighting-raiser column and that the admin row type has no field for it, modelled on the existing test guarding the Bookmark column list
  • Filter names never reach query text: predicates are compile-time constants selected by name, Site and Kind are bound parameters
  • The aggregate is one grouped pass, not one query per figure; library-wide totals are summed in Go over the Sites
  • The store stays clockless: the staleness cutoff arrives from the caller
  • go test ./... green

Blocked by

None — can start immediately.

## Parent Spec #134. ## What to build Every read of a Series today is scoped to one Reader, so the owner cannot ask library-wide questions: how many Series have no Cover, which ones has the poller never read a chapter from, which ones does nobody hold any more. Those rows accumulate -- removing a Bookmark leaves the Series behind and nothing ever deletes one. This ticket lands the two store reads that answer those questions, and the privacy boundary that keeps them anonymous. No page changes here; it is verified at the store seam. A filter value object (Site, Library kind, filter name, a caller-supplied staleness cutoff, a 1-based page) feeds two methods: a row read returning rows plus the filtered total, and a per-Site aggregate. **Two methods, not one** -- the landing page reads only the aggregate and must not pay for fifty rows it discards. The filter vocabulary is shared by being one set of named predicate constants, not by being one method. Eight filter names: no series URL, never read a chapter, no Readers, never checked, not checked in the window, no cover, latest from a Reader's report, and the absent/unknown case meaning all Series. Ordered permanent-and-fixable first, and labelled as the repair they need rather than as the SQL they are -- the owner arrives to act, not to admire a predicate. Two notes that are easy to get wrong: "no series URL" means an **empty** URL only (the host-fails-the-fetch-gate case is invisible to SQL, needs the Site registry in Go, and would break both the count and the pager if filtered after the read -- it is a repair, and belongs to a later spec); and "never read a chapter" and "never checked" are disjoint by construction (non-zero versus zero check stamp), so the two counts never double-report a row. The row query is a **LEFT JOIN** to Bookmarks, not a JOIN: orphans are exactly what hygiene has to find, and the Lane's join hides them. A zero Reader count *is* the orphan marker, and it is an anonymous Series-level fact. Reader count is a plain count of every Bookmark on the Series, which knowingly disagrees with the two Lane queries for as long as the finished Lifecycle bucket exists -- a later spec removes that bucket, after which the definitions are the same set. Recorded rather than hidden. **The tie-break is mandatory.** Every unpollable Series has a zero check stamp, so ordering on that column alone gives no stable page boundary and rows repeat or vanish across pages. 50 rows a page; the filtered total comes from a window count in the same query, because window functions run after grouping and before the limit, so one where-clause cannot disagree with a second copy of itself. **The privacy boundary is the projection, not a template.** The existing Series column list carries the Reader id that raised a Sighting, so reusing it would put Reader identity in the admin handler and leave the boundary resting on a template that happens not to print a field. The admin row type has no field for it and that column never leaves the store package. A boolean answering "a Reader's report set this number" is computed in SQL instead, so the owner learns which Series to look at and nothing about which Reader. Also lands the two schema pieces the read model and the next tickets need, as an expand step: a `series (latest_checked_at)` index (the table has only its primary key today -- this supports the default order and is **a judgement, not a measurement**, since a grouped query over a join may ignore it; re-time on real data before adding a second), and a `force_poll_at` column defaulting to zero, unused until the Forced Poll ticket wires it. ## Acceptance criteria - [ ] Each of the eight filter predicates returns the rows it names and no others against seeded rows, including the orphan case only a left join can see - [ ] "Never read a chapter" and "never checked" are proven disjoint over a seeded mix - [ ] The paging tie-break holds with several rows sharing a zero check stamp: no row repeats or vanishes across pages - [ ] The filtered total agrees with the row count for the same filter, and the aggregate agrees with the row query for the same filter - [ ] A dedicated test asserts the admin column constant does not mention the Sighting-raiser column and that the admin row type has no field for it, modelled on the existing test guarding the Bookmark column list - [ ] Filter names never reach query text: predicates are compile-time constants selected by name, Site and Kind are bound parameters - [ ] The aggregate is one grouped pass, not one query per figure; library-wide totals are summed in Go over the Sites - [ ] The store stays clockless: the staleness cutoff arrives from the caller - [ ] `go test ./...` green ## Blocked by None — can start immediately.
sulthan added the ready-for-agent label 2026-08-21 16:39:20 +07:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sulthan/mangaBookmark#140