feat(series): show missing entry gaps as ghost cards (bookshelf-4ztq.1) #694

Merged
zombor merged 4 commits from bd-bookshelf-4ztq.1 into main 2026-06-22 17:50:07 +00:00
Owner

Summary

  • Adds computeMissingEntries (pure function) that derives interior and trailing series gaps from owned series_number values vs series_total — interior-only when total is unknown (0), full range including tail when series_total > max owned
  • Adds buildSeriesEntries to merge owned books + ghost placeholders into a single sorted []SeriesEntry slice for the template
  • SeriesDetail gains MissingEntries, TotalKnown, and Entries fields; populated in buildSeriesDetail — no schema change
  • Series Detail page (both grid and Book List tab) renders ghost placeholder cards interleaved with owned books, sorted by series_number; header shows "OWN N OF M" summary
  • Ghost cards use dashed-border cover-card--ghost and book-card-tile--ghost CSS (canonical cover-card classes extended, no bespoke system)
  • Multi-user: ownedNumbers scoped to userLibraryIDs fail-closed, same as existing booksInSeries query
  • 100% coverage gate maintained; 260 unit tests pass

Test plan

  • make test — all 260 series tests pass
  • make coverage — gate passes (100% on internal/, wire.go excluded)
  • make build — clean compile
  • golangci-lint run ./internal/series/... — 0 issues
  • Screenshot captured: series detail grid showing owned + ghost cards interleaved (2 owned, 3 ghost)
  • Screenshot captured: Book List tab showing ghost rows with MISSING badge

Closes bead bookshelf-4ztq.1 on merge.

## Summary - Adds `computeMissingEntries` (pure function) that derives interior and trailing series gaps from owned `series_number` values vs `series_total` — interior-only when total is unknown (0), full range including tail when `series_total > max owned` - Adds `buildSeriesEntries` to merge owned books + ghost placeholders into a single sorted `[]SeriesEntry` slice for the template - `SeriesDetail` gains `MissingEntries`, `TotalKnown`, and `Entries` fields; populated in `buildSeriesDetail` — no schema change - Series Detail page (both grid and Book List tab) renders ghost placeholder cards interleaved with owned books, sorted by `series_number`; header shows "OWN N OF M" summary - Ghost cards use dashed-border `cover-card--ghost` and `book-card-tile--ghost` CSS (canonical `cover-card` classes extended, no bespoke system) - Multi-user: `ownedNumbers` scoped to `userLibraryIDs` fail-closed, same as existing `booksInSeries` query - 100% coverage gate maintained; 260 unit tests pass ## Test plan - [x] `make test` — all 260 series tests pass - [x] `make coverage` — gate passes (100% on internal/, wire.go excluded) - [x] `make build` — clean compile - [x] `golangci-lint run ./internal/series/...` — 0 issues - [x] Screenshot captured: series detail grid showing owned + ghost cards interleaved (2 owned, 3 ghost) - [x] Screenshot captured: Book List tab showing ghost rows with MISSING badge Closes bead bookshelf-4ztq.1 on merge.
feat(series): show missing entry gaps as ghost cards on series detail page (bookshelf-4ztq.1)
All checks were successful
/ E2E API (pull_request) Successful in 2m32s
/ JS Unit Tests (pull_request) Successful in 36s
/ Lint (pull_request) Successful in 2m48s
/ Integration (pull_request) Successful in 3m40s
/ E2E Browser (pull_request) Successful in 2m56s
/ Test (pull_request) Successful in 4m32s
b0da683904
Computes interior and trailing series gaps from book_metadata.series_number /
series_total (no schema change). Renders ghost placeholder cards interleaved
with owned books on both the Series Details grid and Book List tab, plus an
"own N of M" summary. Multi-user: ownedNumbers scoped to userLibraryIDs
fail-closed. Interior-only gaps when total is unknown; full range including
tail when series_total is set.

Closes bead bookshelf-4ztq.1 on merge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

UI Screenshots

Grid view — owned books + ghost cards interleaved, sorted by series number. "Own N of M" summary in heading.

Series gaps grid

Book List tab — ghost rows with MISSING badge interleaved with owned rows.

Series gaps list

Seeded: Davina Ravine Psychic Crime Thriller Series (series_total=8), owned #4 and #6, showing gaps #5, #7, #8.

## UI Screenshots **Grid view** — owned books + ghost cards interleaved, sorted by series number. "Own N of M" summary in heading. ![Series gaps grid](https://git.zombor.net/attachments/a50041c6-957f-42af-aaed-72a6b8a964b2) **Book List tab** — ghost rows with MISSING badge interleaved with owned rows. ![Series gaps list](https://git.zombor.net/attachments/4f98d4bb-24d7-45d6-940e-2696ae16309c) Seeded: Davina Ravine Psychic Crime Thriller Series (series_total=8), owned #4 and #6, showing gaps #5, #7, #8.
Author
Owner

Security Review — PR #694 (bd-bookshelf-4ztq.1): Series detail ghost cards

Reviewed per .claude/rules/review-standard.md. Diff-only — no tests re-run.


[MAJOR] internal/series/store.go:354 — BooksInSeries missing fail-closed guard for empty userLibraryIDs

BooksInSeries applies the library filter only when len(userLibraryIDs) > 0. When GetUserLibraryIDs returns a non-nil empty slice (the documented fail-closed signal for "authenticated user with no library access"), the library filter is silently omitted and all books in the series are returned unscoped — leaking the series contents to any authenticated user regardless of library access.

The correct pattern (already used in three other functions in this file, e.g. lines 77–80 and 211) is:

if userLibraryIDs != nil && len(userLibraryIDs) == 0 {
    return nil, nil  // fail-closed: no access
}
if len(userLibraryIDs) > 0 {
    // append AND b.library_id IN (...)
}

Fix: add the nil != nil && len == 0 → return nil, nil guard before the existing len > 0 branch, exactly as ListSeriesCovers does at line 211.


[MAJOR] internal/series/series.go:76–86 — Unbounded seriesTotal loop; no cap on ghost-card count

computeMissingEntries loops from rangeMin to rangeMax = max(maxOwned, seriesTotal) with no upper bound. series_total is a plain INT column, user-writable via POST /books/{id}/metadata (series_total form field — metadata_handler.go:289) with no validation. An authenticated user who edits any book to series_total = 2,000,000,000 and then views its series page will cause the server to allocate a 2-billion-element []MissingEntry slice and render 2 billion ghost cards, consuming all available memory and crashing the process (DoS).

Fix: cap the effective range before the loop, e.g.:

const maxGhostCards = 500
if rangeMax-rangeMin > maxGhostCards {
    rangeMax = rangeMin + maxGhostCards
}

(Or clamp seriesTotal to a reasonable ceiling, e.g. 1000, in buildSeriesDetail before it is passed to computeMissingEntries.)


[MINOR] templates/pages/series_show.html — Ghost card aria-label uses printf "%.0f" on SeriesNumber

SeriesNumber is a float64 rendered as an integer via printf "%.0f". Values that do not fit in the displayed range (or rounding artefacts for large floats) could produce misleading labels. This is cosmetic-only (no security impact) since html/template auto-escapes the output. No action required before merge, but worth noting.


XSS — CLEAR: All user-controlled strings (series name, book title, author name) render via {{.Title}} / {{$a.Name}} through html/template's auto-escaping. No template.HTML cast or safeHTML call found in the diff. Ghost-card series numbers are integers rendered via printf "%.0f" — not user-supplied strings. No XSS risk.

SQL injection — CLEAR: BooksInSeries builds ? placeholder strings programmatically and passes all values as args — no raw string interpolation of user data.

Series existence leak — CLEAR: GetDetail returns middleware.ErrNotFound when booksInSeries returns zero rows (after the library filter is applied), so a user with no library access to a series cannot discover its existence via the detail page.


REVIEW VERDICT: 0 blocker, 2 major, 1 minor

## Security Review — PR #694 (bd-bookshelf-4ztq.1): Series detail ghost cards Reviewed per `.claude/rules/review-standard.md`. Diff-only — no tests re-run. --- ### [MAJOR] internal/series/store.go:354 — BooksInSeries missing fail-closed guard for empty `userLibraryIDs` `BooksInSeries` applies the library filter only when `len(userLibraryIDs) > 0`. When `GetUserLibraryIDs` returns a non-nil empty slice (the documented fail-closed signal for "authenticated user with no library access"), the library filter is silently omitted and **all books in the series are returned unscoped** — leaking the series contents to any authenticated user regardless of library access. The correct pattern (already used in three other functions in this file, e.g. lines 77–80 and 211) is: ```go if userLibraryIDs != nil && len(userLibraryIDs) == 0 { return nil, nil // fail-closed: no access } if len(userLibraryIDs) > 0 { // append AND b.library_id IN (...) } ``` Fix: add the `nil != nil && len == 0 → return nil, nil` guard **before** the existing `len > 0` branch, exactly as `ListSeriesCovers` does at line 211. --- ### [MAJOR] internal/series/series.go:76–86 — Unbounded `seriesTotal` loop; no cap on ghost-card count `computeMissingEntries` loops from `rangeMin` to `rangeMax = max(maxOwned, seriesTotal)` with no upper bound. `series_total` is a plain `INT` column, user-writable via `POST /books/{id}/metadata` (`series_total` form field — `metadata_handler.go:289`) with no validation. An authenticated user who edits any book to `series_total = 2,000,000,000` and then views its series page will cause the server to allocate a 2-billion-element `[]MissingEntry` slice and render 2 billion ghost cards, consuming all available memory and crashing the process (DoS). Fix: cap the effective range before the loop, e.g.: ```go const maxGhostCards = 500 if rangeMax-rangeMin > maxGhostCards { rangeMax = rangeMin + maxGhostCards } ``` (Or clamp `seriesTotal` to a reasonable ceiling, e.g. 1000, in `buildSeriesDetail` before it is passed to `computeMissingEntries`.) --- ### [MINOR] templates/pages/series_show.html — Ghost card `aria-label` uses `printf "%.0f"` on `SeriesNumber` `SeriesNumber` is a `float64` rendered as an integer via `printf "%.0f"`. Values that do not fit in the displayed range (or rounding artefacts for large floats) could produce misleading labels. This is cosmetic-only (no security impact) since `html/template` auto-escapes the output. No action required before merge, but worth noting. --- **XSS — CLEAR:** All user-controlled strings (`series name`, `book title`, `author name`) render via `{{.Title}}` / `{{$a.Name}}` through `html/template`'s auto-escaping. No `template.HTML` cast or `safeHTML` call found in the diff. Ghost-card series numbers are integers rendered via `printf "%.0f"` — not user-supplied strings. No XSS risk. **SQL injection — CLEAR:** `BooksInSeries` builds `? `placeholder strings programmatically and passes all values as `args` — no raw string interpolation of user data. **Series existence leak — CLEAR:** `GetDetail` returns `middleware.ErrNotFound` when `booksInSeries` returns zero rows (after the library filter is applied), so a user with no library access to a series cannot discover its existence via the detail page. --- REVIEW VERDICT: 0 blocker, 2 major, 1 minor
Author
Owner

CODE REVIEW: NOT APPROVED

Phase 0: DEMO Verification

No runnable CLI DEMO block exists in the bead comments. The implementer posted two rendered screenshots (series_gaps_grid.png, series_gaps_list.png) as the visual proof. Screenshots were downloaded and inspected. The screenshots actually expose the BLOCKER rather than verifying correctness: the seeded scenario (owned #4 and #6 in an 8-book series) shows ghost cards only for #5, #7, #8 — entries #1, #2, #3 are entirely absent from both the grid and the book-list tab. The heading shows "OWN 2 OF 8" confirming series_total=8 is known, so gaps #1–#3 should be visible but are not.


Phase 1 & 2: Findings


[BLOCKER] internal/series/series.go:134–138 — computeMissingEntries starts at minOwned, not 1, when seriesTotal > maxOwned

The docstring at line 40 explicitly states the totalKnown strategy is "gaps in 1..seriesTotal". The implementation sets rangeMin = minOwned unconditionally (line 134) and only extends rangeMax to seriesTotal (lines 136–138). When seriesTotal > maxOwned, the loop runs minOwned..seriesTotal — so any entry before the first owned book is silently dropped.

Concrete example: series_total=8, owned={#4, #6}. Expected missing: #1,#2,#3,#5,#7,#8. Actual: #5,#7,#8. The screenshots confirm this exact bug — #1, #2, #3 are invisible. The test at line 340 ("returns interior gaps and tail when seriesTotal > maxOwned") uses ownedNumbers=[1,3] where minOwned=1, so it cannot catch this.

Fix: when seriesTotal > maxOwned, set rangeMin = 1 instead of minOwned. Also add a test: ownedNumbers=[3,5], seriesTotal=7 → expected missing [1,2,4,6,7].


[MAJOR] internal/series/service.go:243–250 — buildSeriesEntries sort comparator violates strict-weak-ordering for two owned books at the same series_number

The comparator at line 248: return !entries[i].Missing. When both i and j are owned books (Missing=false) at the same series_number, less(i,j) = true AND less(j,i) = true — both claim to be less than each other. This violates antisymmetry. Go's sort.SliceStable won't panic but produces undefined ordering for that pair on every page render (non-deterministic display output for users with duplicate series numbers in their metadata).

Fix: add a stable tiebreaker — when ni == nj and neither is missing, tiebreak on book ID: return entries[i].Book.ID < entries[j].Book.ID. Guard the nil-Book case (ghost entries have Book=nil).


[MINOR] internal/series/service.go:170 — TotalKnown set true whenever seriesTotal > 0, even when seriesTotal <= maxOwned

totalKnown := seriesTotal > 0 (line 170) fires regardless of whether seriesTotal is actually used as the range ceiling (which only happens when seriesTotal > maxOwned). Result: when a user owns books numbered past seriesTotal (e.g., own #1 and #5, seriesTotal=3), the template renders "own 2 of 3" while the ghost cards show gaps through #5. The docstring (line 41) says totalKnown means "seriesTotal > 0 and > max owned", but the code doesn't match. Low real-world impact (requires bad metadata), but the field name is misleading and the label can be wrong.

Fix: totalKnown := seriesTotal > 0 && seriesTotal > maxOwned (requires maxOwned to be accessible at that point in buildSeriesDetail — it already is, since computeMissingEntries receives it).


Phase 0 summary

The screenshot DEMO, while not a runnable command, functions as a visual verification. It reveals the BLOCKER directly: entries before the first owned book are not shown even when the total is known.

REVIEW VERDICT: 1 blocker, 1 major, 1 minor

## CODE REVIEW: NOT APPROVED ### Phase 0: DEMO Verification No runnable CLI DEMO block exists in the bead comments. The implementer posted two rendered screenshots (`series_gaps_grid.png`, `series_gaps_list.png`) as the visual proof. Screenshots were downloaded and inspected. The screenshots actually **expose the BLOCKER** rather than verifying correctness: the seeded scenario (owned #4 and #6 in an 8-book series) shows ghost cards only for #5, #7, #8 — entries #1, #2, #3 are entirely absent from both the grid and the book-list tab. The heading shows "OWN 2 OF 8" confirming `series_total=8` is known, so gaps #1–#3 should be visible but are not. --- ### Phase 1 & 2: Findings --- [BLOCKER] internal/series/series.go:134–138 — `computeMissingEntries` starts at `minOwned`, not `1`, when `seriesTotal > maxOwned` The docstring at line 40 explicitly states the `totalKnown` strategy is "gaps in 1..seriesTotal". The implementation sets `rangeMin = minOwned` unconditionally (line 134) and only extends `rangeMax` to `seriesTotal` (lines 136–138). When `seriesTotal > maxOwned`, the loop runs `minOwned..seriesTotal` — so any entry before the first owned book is silently dropped. Concrete example: series_total=8, owned={#4, #6}. Expected missing: #1,#2,#3,#5,#7,#8. Actual: #5,#7,#8. The screenshots confirm this exact bug — #1, #2, #3 are invisible. The test at line 340 (`"returns interior gaps and tail when seriesTotal > maxOwned"`) uses `ownedNumbers=[1,3]` where `minOwned=1`, so it cannot catch this. Fix: when `seriesTotal > maxOwned`, set `rangeMin = 1` instead of `minOwned`. Also add a test: `ownedNumbers=[3,5], seriesTotal=7` → expected missing `[1,2,4,6,7]`. --- [MAJOR] internal/series/service.go:243–250 — `buildSeriesEntries` sort comparator violates strict-weak-ordering for two owned books at the same `series_number` The comparator at line 248: `return !entries[i].Missing`. When both `i` and `j` are owned books (`Missing=false`) at the same `series_number`, `less(i,j) = true` AND `less(j,i) = true` — both claim to be less than each other. This violates antisymmetry. Go's `sort.SliceStable` won't panic but produces undefined ordering for that pair on every page render (non-deterministic display output for users with duplicate series numbers in their metadata). Fix: add a stable tiebreaker — when `ni == nj` and neither is missing, tiebreak on book ID: `return entries[i].Book.ID < entries[j].Book.ID`. Guard the nil-Book case (ghost entries have `Book=nil`). --- [MINOR] internal/series/service.go:170 — `TotalKnown` set `true` whenever `seriesTotal > 0`, even when `seriesTotal <= maxOwned` `totalKnown := seriesTotal > 0` (line 170) fires regardless of whether `seriesTotal` is actually used as the range ceiling (which only happens when `seriesTotal > maxOwned`). Result: when a user owns books numbered past `seriesTotal` (e.g., own #1 and #5, seriesTotal=3), the template renders "own 2 of 3" while the ghost cards show gaps through #5. The docstring (line 41) says `totalKnown` means "seriesTotal > 0 **and** > max owned", but the code doesn't match. Low real-world impact (requires bad metadata), but the field name is misleading and the label can be wrong. Fix: `totalKnown := seriesTotal > 0 && seriesTotal > maxOwned` (requires `maxOwned` to be accessible at that point in `buildSeriesDetail` — it already is, since `computeMissingEntries` receives it). --- ### Phase 0 summary The screenshot DEMO, while not a runnable command, functions as a visual verification. It reveals the BLOCKER directly: entries before the first owned book are not shown even when the total is known. REVIEW VERDICT: 1 blocker, 1 major, 1 minor
Author
Owner

Security Review — PR #694 (bd-bookshelf-4ztq.1): Series detail ghost cards

Reviewed per .claude/rules/review-standard.md. Diff-only — no tests re-run.


[MAJOR] internal/series/store.go:354 — BooksInSeries missing fail-closed guard for empty userLibraryIDs

BooksInSeries applies the library filter only when len(userLibraryIDs) > 0. When GetUserLibraryIDs returns a non-nil empty slice (the documented fail-closed signal for “authenticated user with no library access”), the library filter is silently omitted and all books in the series are returned unscoped — leaking the series contents to any authenticated user regardless of library access.

The correct pattern (already used in three other functions in this file, e.g. lines 77–80 and 211) is:

if userLibraryIDs != nil && len(userLibraryIDs) == 0 {
    return nil, nil  // fail-closed: no access
}
if len(userLibraryIDs) > 0 {
    // append AND b.library_id IN (...)
}

Fix: add the nil != nil && len == 0 -> return nil, nil guard before the existing len > 0 branch, exactly as ListSeriesCovers does at line 211.


[MAJOR] internal/series/series.go:76-86 — Unbounded seriesTotal loop; no cap on ghost-card count

computeMissingEntries loops from rangeMin to rangeMax = max(maxOwned, seriesTotal) with no upper bound. series_total is a plain INT column, user-writable via POST /books/{id}/metadata (series_total form field — metadata_handler.go:289) with no validation. An authenticated user who edits any book to series_total = 2,000,000,000 and then views its series page will cause the server to allocate a 2-billion-element []MissingEntry slice and render 2 billion ghost cards, consuming all available memory and crashing the process (DoS).

Fix: cap the effective range before the loop, e.g.:

const maxGhostCards = 500
if rangeMax-rangeMin > maxGhostCards {
    rangeMax = rangeMin + maxGhostCards
}

Or clamp seriesTotal to a reasonable ceiling (e.g. 1000) in buildSeriesDetail before passing it to computeMissingEntries.


[MINOR] templates/pages/series_show.html — Ghost card aria-label uses printf %.0f on SeriesNumber

SeriesNumber is a float64 rendered as an integer via printf "%.0f". Values that do not fit in the displayed range (or rounding artefacts for large floats) could produce misleading labels. This is cosmetic-only (no security impact) since html/template auto-escapes the output. No action required before merge, but worth noting.


XSS — CLEAR: All user-controlled strings (series name, book title, author name) render via {{.Title}} / {{$a.Name}} through html/template's auto-escaping. No template.HTML cast or safeHTML call found in the diff. Ghost-card series numbers are integers rendered via printf "%.0f" — not user-supplied strings. No XSS risk.

SQL injection — CLEAR: BooksInSeries builds ? placeholder strings programmatically and passes all values as args — no raw string interpolation of user data.

Series existence leak — CLEAR: GetDetail returns middleware.ErrNotFound when booksInSeries returns zero rows (after the library filter is applied), so a user with no library access to a series cannot discover its existence via the detail page.


REVIEW VERDICT: 0 blocker, 2 major, 1 minor

## Security Review — PR #694 (bd-bookshelf-4ztq.1): Series detail ghost cards Reviewed per `.claude/rules/review-standard.md`. Diff-only — no tests re-run. --- ### [MAJOR] internal/series/store.go:354 — BooksInSeries missing fail-closed guard for empty `userLibraryIDs` `BooksInSeries` applies the library filter only when `len(userLibraryIDs) > 0`. When `GetUserLibraryIDs` returns a non-nil empty slice (the documented fail-closed signal for “authenticated user with no library access”), the library filter is silently omitted and **all books in the series are returned unscoped** — leaking the series contents to any authenticated user regardless of library access. The correct pattern (already used in three other functions in this file, e.g. lines 77–80 and 211) is: ```go if userLibraryIDs != nil && len(userLibraryIDs) == 0 { return nil, nil // fail-closed: no access } if len(userLibraryIDs) > 0 { // append AND b.library_id IN (...) } ``` Fix: add the `nil != nil && len == 0 -> return nil, nil` guard **before** the existing `len > 0` branch, exactly as `ListSeriesCovers` does at line 211. --- ### [MAJOR] internal/series/series.go:76-86 — Unbounded `seriesTotal` loop; no cap on ghost-card count `computeMissingEntries` loops from `rangeMin` to `rangeMax = max(maxOwned, seriesTotal)` with no upper bound. `series_total` is a plain INT column, user-writable via `POST /books/{id}/metadata` (`series_total` form field — `metadata_handler.go:289`) with no validation. An authenticated user who edits any book to `series_total = 2,000,000,000` and then views its series page will cause the server to allocate a 2-billion-element `[]MissingEntry` slice and render 2 billion ghost cards, consuming all available memory and crashing the process (DoS). Fix: cap the effective range before the loop, e.g.: ```go const maxGhostCards = 500 if rangeMax-rangeMin > maxGhostCards { rangeMax = rangeMin + maxGhostCards } ``` Or clamp `seriesTotal` to a reasonable ceiling (e.g. 1000) in `buildSeriesDetail` before passing it to `computeMissingEntries`. --- ### [MINOR] templates/pages/series_show.html — Ghost card `aria-label` uses `printf %.0f` on `SeriesNumber` `SeriesNumber` is a `float64` rendered as an integer via `printf "%.0f"`. Values that do not fit in the displayed range (or rounding artefacts for large floats) could produce misleading labels. This is cosmetic-only (no security impact) since `html/template` auto-escapes the output. No action required before merge, but worth noting. --- **XSS — CLEAR:** All user-controlled strings (`series name`, `book title`, `author name`) render via `{{.Title}}` / `{{$a.Name}}` through `html/template`'s auto-escaping. No `template.HTML` cast or `safeHTML` call found in the diff. Ghost-card series numbers are integers rendered via `printf "%.0f"` — not user-supplied strings. No XSS risk. **SQL injection — CLEAR:** `BooksInSeries` builds `?` placeholder strings programmatically and passes all values as `args` — no raw string interpolation of user data. **Series existence leak — CLEAR:** `GetDetail` returns `middleware.ErrNotFound` when `booksInSeries` returns zero rows (after the library filter is applied), so a user with no library access to a series cannot discover its existence via the detail page. --- REVIEW VERDICT: 0 blocker, 2 major, 1 minor
Author
Owner

CODE REVIEW: NOT APPROVED

Phase 0: DEMO Verification

No runnable CLI DEMO block exists in the bead comments. The implementer posted two rendered screenshots as the visual proof. Screenshots were downloaded and inspected. The screenshots actually expose the BLOCKER rather than verifying correctness: the seeded scenario (owned #4 and #6 in an 8-book series) shows ghost cards only for #5, #7, #8 — entries #1, #2, #3 are entirely absent from both the grid and the book-list tab. The heading shows "OWN 2 OF 8" confirming series_total=8 is known, so gaps #1–#3 should be visible but are not.


Phase 1 & 2: Findings

[BLOCKER] internal/series/series.go:134-138 — computeMissingEntries starts at minOwned, not 1, when seriesTotal > maxOwned

The docstring at line 40 explicitly states the totalKnown strategy is "gaps in 1..seriesTotal". The implementation sets rangeMin = minOwned unconditionally (line 134) and only extends rangeMax to seriesTotal (lines 136–138). When seriesTotal > maxOwned, the loop runs minOwned..seriesTotal — so any entry before the first owned book is silently dropped.

Concrete example: series_total=8, owned={#4, #6}. Expected missing: #1,#2,#3,#5,#7,#8. Actual: #5,#7,#8. The screenshots confirm this exact bug — #1, #2, #3 are invisible. The test at line 340 ("returns interior gaps and tail when seriesTotal > maxOwned") uses ownedNumbers=[1,3] where minOwned=1, so it cannot catch this.

Fix: when seriesTotal > maxOwned, set rangeMin = 1 instead of minOwned. Also add a test: ownedNumbers=[3,5], seriesTotal=7 → expected missing [1,2,4,6,7].


[MAJOR] internal/series/service.go:243-250 — buildSeriesEntries sort comparator violates strict-weak-ordering for two owned books at the same series_number

The comparator at line 248: return !entries[i].Missing. When both i and j are owned books (Missing=false) at the same series_number, less(i,j) = true AND less(j,i) = true — both claim to be less than each other. This violates antisymmetry. Go's sort.SliceStable won't panic but produces undefined ordering for that pair on every page render (non-deterministic display for users with duplicate series numbers in their metadata).

Fix: add a stable tiebreaker — when ni == nj and neither is missing, tiebreak on book ID: entries[i].Book.ID < entries[j].Book.ID. Guard the nil-Book case (ghost entries have Book=nil).


[MINOR] internal/series/service.go:170 — TotalKnown set true whenever seriesTotal > 0, even when seriesTotal <= maxOwned

totalKnown := seriesTotal > 0 fires regardless of whether seriesTotal is actually used as the range ceiling (which only happens when seriesTotal > maxOwned). When a user owns books numbered past seriesTotal (e.g., own #1 and #5, seriesTotal=3), the template renders "own 2 of 3" while ghost cards show gaps through #5. The docstring at line 41 says totalKnown means "seriesTotal > 0 AND > max owned", but the code doesn't match.

Fix: totalKnown := seriesTotal > 0 && seriesTotal > maxOwned (maxOwned is already available in buildSeriesDetail at that point).


REVIEW VERDICT: 1 blocker, 1 major, 1 minor

## CODE REVIEW: NOT APPROVED ### Phase 0: DEMO Verification No runnable CLI DEMO block exists in the bead comments. The implementer posted two rendered screenshots as the visual proof. Screenshots were downloaded and inspected. The screenshots actually **expose the BLOCKER** rather than verifying correctness: the seeded scenario (owned #4 and #6 in an 8-book series) shows ghost cards only for #5, #7, #8 — entries #1, #2, #3 are entirely absent from both the grid and the book-list tab. The heading shows "OWN 2 OF 8" confirming series_total=8 is known, so gaps #1–#3 should be visible but are not. --- ### Phase 1 & 2: Findings [BLOCKER] internal/series/series.go:134-138 — computeMissingEntries starts at minOwned, not 1, when seriesTotal > maxOwned The docstring at line 40 explicitly states the totalKnown strategy is "gaps in 1..seriesTotal". The implementation sets rangeMin = minOwned unconditionally (line 134) and only extends rangeMax to seriesTotal (lines 136–138). When seriesTotal > maxOwned, the loop runs minOwned..seriesTotal — so any entry before the first owned book is silently dropped. Concrete example: series_total=8, owned={#4, #6}. Expected missing: #1,#2,#3,#5,#7,#8. Actual: #5,#7,#8. The screenshots confirm this exact bug — #1, #2, #3 are invisible. The test at line 340 ("returns interior gaps and tail when seriesTotal > maxOwned") uses ownedNumbers=[1,3] where minOwned=1, so it cannot catch this. Fix: when seriesTotal > maxOwned, set rangeMin = 1 instead of minOwned. Also add a test: ownedNumbers=[3,5], seriesTotal=7 → expected missing [1,2,4,6,7]. --- [MAJOR] internal/series/service.go:243-250 — buildSeriesEntries sort comparator violates strict-weak-ordering for two owned books at the same series_number The comparator at line 248: `return !entries[i].Missing`. When both i and j are owned books (Missing=false) at the same series_number, less(i,j) = true AND less(j,i) = true — both claim to be less than each other. This violates antisymmetry. Go's sort.SliceStable won't panic but produces undefined ordering for that pair on every page render (non-deterministic display for users with duplicate series numbers in their metadata). Fix: add a stable tiebreaker — when ni == nj and neither is missing, tiebreak on book ID: `entries[i].Book.ID < entries[j].Book.ID`. Guard the nil-Book case (ghost entries have Book=nil). --- [MINOR] internal/series/service.go:170 — TotalKnown set true whenever seriesTotal > 0, even when seriesTotal <= maxOwned `totalKnown := seriesTotal > 0` fires regardless of whether seriesTotal is actually used as the range ceiling (which only happens when seriesTotal > maxOwned). When a user owns books numbered past seriesTotal (e.g., own #1 and #5, seriesTotal=3), the template renders "own 2 of 3" while ghost cards show gaps through #5. The docstring at line 41 says totalKnown means "seriesTotal > 0 AND > max owned", but the code doesn't match. Fix: `totalKnown := seriesTotal > 0 && seriesTotal > maxOwned` (maxOwned is already available in buildSeriesDetail at that point). --- REVIEW VERDICT: 1 blocker, 1 major, 1 minor
Author
Owner

Security Review — PR #694 (bd-bookshelf-4ztq.1): Series detail ghost cards

Reviewed per .claude/rules/review-standard.md. Diff-only — no tests re-run.


[MAJOR] internal/series/store.go:354 — BooksInSeries missing fail-closed guard for empty userLibraryIDs

BooksInSeries applies the library filter only when len(userLibraryIDs) > 0. When GetUserLibraryIDs returns a non-nil empty slice (the documented fail-closed signal for 'authenticated user with no library access'), the library filter is silently omitted and all books in the series are returned unscoped — leaking the series contents to any authenticated user regardless of library access.

The correct pattern (already used in three other functions in this file, e.g. lines 77-80 and 211) is:

if userLibraryIDs != nil && len(userLibraryIDs) == 0 {
    return nil, nil  // fail-closed: no access
}
if len(userLibraryIDs) > 0 {
    // append AND b.library_id IN (...)
}

Fix: add the nil check && len == 0 -> return nil, nil guard before the existing len > 0 branch, exactly as ListSeriesCovers does at line 211.


[MAJOR] internal/series/series.go:76-86 — Unbounded seriesTotal loop; no cap on ghost-card count

computeMissingEntries loops from rangeMin to rangeMax = max(maxOwned, seriesTotal) with no upper bound. series_total is a plain INT column, user-writable via POST /books/{id}/metadata (series_total form field — metadata_handler.go:289) with no validation. An authenticated user who edits any book to series_total = 2,000,000,000 and then views its series page will cause the server to allocate a 2-billion-element []MissingEntry slice and render 2 billion ghost cards, consuming all available memory and crashing the process (DoS).

Fix: cap the effective range before the loop, e.g.:

const maxGhostCards = 500
if rangeMax-rangeMin > maxGhostCards {
    rangeMax = rangeMin + maxGhostCards
}

Or clamp seriesTotal to a reasonable ceiling (e.g. 1000) in buildSeriesDetail before passing it to computeMissingEntries.


[MINOR] templates/pages/series_show.html — Ghost card aria-label uses printf %.0f on SeriesNumber

SeriesNumber is a float64 rendered as an integer via printf "%.0f". Values that do not fit in the displayed range (or rounding artefacts for large floats) could produce misleading labels. This is cosmetic-only (no security impact) since html/template auto-escapes the output. No action required before merge, but worth noting.


XSS — CLEAR: All user-controlled strings render via html/template auto-escaping. No template.HTML cast or safeHTML call found in the diff. No XSS risk.

SQL injection — CLEAR: BooksInSeries builds ? placeholder strings programmatically and passes all values as args — no raw string interpolation of user data.

Series existence leak — CLEAR: GetDetail returns middleware.ErrNotFound when booksInSeries returns zero rows (after the library filter is applied), so a user with no library access to a series cannot discover its existence via the detail page.


REVIEW VERDICT: 0 blocker, 2 major, 1 minor

## Security Review — PR #694 (bd-bookshelf-4ztq.1): Series detail ghost cards Reviewed per `.claude/rules/review-standard.md`. Diff-only — no tests re-run. --- ### [MAJOR] internal/series/store.go:354 — BooksInSeries missing fail-closed guard for empty `userLibraryIDs` `BooksInSeries` applies the library filter only when `len(userLibraryIDs) > 0`. When `GetUserLibraryIDs` returns a non-nil empty slice (the documented fail-closed signal for 'authenticated user with no library access'), the library filter is silently omitted and **all books in the series are returned unscoped** — leaking the series contents to any authenticated user regardless of library access. The correct pattern (already used in three other functions in this file, e.g. lines 77-80 and 211) is: ```go if userLibraryIDs != nil && len(userLibraryIDs) == 0 { return nil, nil // fail-closed: no access } if len(userLibraryIDs) > 0 { // append AND b.library_id IN (...) } ``` Fix: add the `nil check && len == 0 -> return nil, nil` guard **before** the existing `len > 0` branch, exactly as `ListSeriesCovers` does at line 211. --- ### [MAJOR] internal/series/series.go:76-86 — Unbounded `seriesTotal` loop; no cap on ghost-card count `computeMissingEntries` loops from `rangeMin` to `rangeMax = max(maxOwned, seriesTotal)` with no upper bound. `series_total` is a plain INT column, user-writable via `POST /books/{id}/metadata` (`series_total` form field — `metadata_handler.go:289`) with no validation. An authenticated user who edits any book to `series_total = 2,000,000,000` and then views its series page will cause the server to allocate a 2-billion-element `[]MissingEntry` slice and render 2 billion ghost cards, consuming all available memory and crashing the process (DoS). Fix: cap the effective range before the loop, e.g.: ```go const maxGhostCards = 500 if rangeMax-rangeMin > maxGhostCards { rangeMax = rangeMin + maxGhostCards } ``` Or clamp `seriesTotal` to a reasonable ceiling (e.g. 1000) in `buildSeriesDetail` before passing it to `computeMissingEntries`. --- ### [MINOR] templates/pages/series_show.html — Ghost card `aria-label` uses `printf %.0f` on `SeriesNumber` `SeriesNumber` is a `float64` rendered as an integer via `printf "%.0f"`. Values that do not fit in the displayed range (or rounding artefacts for large floats) could produce misleading labels. This is cosmetic-only (no security impact) since `html/template` auto-escapes the output. No action required before merge, but worth noting. --- **XSS — CLEAR:** All user-controlled strings render via `html/template` auto-escaping. No `template.HTML` cast or `safeHTML` call found in the diff. No XSS risk. **SQL injection — CLEAR:** `BooksInSeries` builds `?` placeholder strings programmatically and passes all values as `args` — no raw string interpolation of user data. **Series existence leak — CLEAR:** `GetDetail` returns `middleware.ErrNotFound` when `booksInSeries` returns zero rows (after the library filter is applied), so a user with no library access to a series cannot discover its existence via the detail page. --- REVIEW VERDICT: 0 blocker, 2 major, 1 minor
Author
Owner

CODE REVIEW: NOT APPROVED

Phase 0: DEMO Verification

No runnable CLI DEMO block exists in the bead comments. The implementer posted two rendered screenshots as the visual proof. Screenshots were downloaded and inspected. The screenshots actually expose the BLOCKER rather than verifying correctness: the seeded scenario (owned #4 and #6 in an 8-book series) shows ghost cards only for #5, #7, #8 — entries #1, #2, #3 are entirely absent from both the grid and the book-list tab. The heading shows "OWN 2 OF 8" confirming series_total=8 is known, so gaps #1–#3 should be visible but are not.


Phase 1 & 2: Findings

[BLOCKER] internal/series/series.go:134-138 — computeMissingEntries starts at minOwned, not 1, when seriesTotal > maxOwned

The docstring at line 40 explicitly states the totalKnown strategy is "gaps in 1..seriesTotal". The implementation sets rangeMin = minOwned unconditionally (line 134) and only extends rangeMax to seriesTotal (lines 136–138). When seriesTotal > maxOwned, the loop runs minOwned..seriesTotal — so any entry before the first owned book is silently dropped.

Concrete example: series_total=8, owned={#4, #6}. Expected missing: #1,#2,#3,#5,#7,#8. Actual: #5,#7,#8. The screenshots confirm this exact bug — #1, #2, #3 are invisible. The test at line 340 ("returns interior gaps and tail when seriesTotal > maxOwned") uses ownedNumbers=[1,3] where minOwned=1, so it cannot catch this.

Fix: when seriesTotal > maxOwned, set rangeMin = 1 instead of minOwned. Also add a test: ownedNumbers=[3,5], seriesTotal=7 → expected missing [1,2,4,6,7].


[MAJOR] internal/series/service.go:243-250 — buildSeriesEntries sort comparator violates strict-weak-ordering for two owned books at the same series_number

The comparator at line 248: return !entries[i].Missing. When both i and j are owned books (Missing=false) at the same series_number, less(i,j) = true AND less(j,i) = true — both claim to be less than each other. This violates antisymmetry. Go's sort.SliceStable won't panic but produces undefined ordering for that pair on every page render (non-deterministic display for users with duplicate series numbers in their metadata).

Fix: add a stable tiebreaker — when ni == nj and neither is missing, tiebreak on book ID: entries[i].Book.ID < entries[j].Book.ID. Guard the nil-Book case (ghost entries have Book=nil).


[MINOR] internal/series/service.go:170 — TotalKnown set true whenever seriesTotal > 0, even when seriesTotal <= maxOwned

totalKnown := seriesTotal > 0 fires regardless of whether seriesTotal is actually used as the range ceiling (which only happens when seriesTotal > maxOwned). When a user owns books numbered past seriesTotal (e.g., own #1 and #5, seriesTotal=3), the template renders "own 2 of 3" while ghost cards show gaps through #5. The docstring at line 41 says totalKnown means "seriesTotal > 0 AND > max owned", but the code doesn't match.

Fix: totalKnown := seriesTotal > 0 && seriesTotal > maxOwned (maxOwned is already available in buildSeriesDetail at that point).


REVIEW VERDICT: 1 blocker, 1 major, 1 minor

## CODE REVIEW: NOT APPROVED ### Phase 0: DEMO Verification No runnable CLI DEMO block exists in the bead comments. The implementer posted two rendered screenshots as the visual proof. Screenshots were downloaded and inspected. The screenshots actually **expose the BLOCKER** rather than verifying correctness: the seeded scenario (owned #4 and #6 in an 8-book series) shows ghost cards only for #5, #7, #8 — entries #1, #2, #3 are entirely absent from both the grid and the book-list tab. The heading shows "OWN 2 OF 8" confirming series_total=8 is known, so gaps #1–#3 should be visible but are not. --- ### Phase 1 & 2: Findings [BLOCKER] internal/series/series.go:134-138 — computeMissingEntries starts at minOwned, not 1, when seriesTotal > maxOwned The docstring at line 40 explicitly states the totalKnown strategy is "gaps in 1..seriesTotal". The implementation sets rangeMin = minOwned unconditionally (line 134) and only extends rangeMax to seriesTotal (lines 136–138). When seriesTotal > maxOwned, the loop runs minOwned..seriesTotal — so any entry before the first owned book is silently dropped. Concrete example: series_total=8, owned={#4, #6}. Expected missing: #1,#2,#3,#5,#7,#8. Actual: #5,#7,#8. The screenshots confirm this exact bug — #1, #2, #3 are invisible. The test at line 340 ("returns interior gaps and tail when seriesTotal > maxOwned") uses ownedNumbers=[1,3] where minOwned=1, so it cannot catch this. Fix: when seriesTotal > maxOwned, set rangeMin = 1 instead of minOwned. Also add a test: ownedNumbers=[3,5], seriesTotal=7 → expected missing [1,2,4,6,7]. --- [MAJOR] internal/series/service.go:243-250 — buildSeriesEntries sort comparator violates strict-weak-ordering for two owned books at the same series_number The comparator at line 248: `return !entries[i].Missing`. When both i and j are owned books (Missing=false) at the same series_number, less(i,j) = true AND less(j,i) = true — both claim to be less than each other. This violates antisymmetry. Go's sort.SliceStable won't panic but produces undefined ordering for that pair on every page render (non-deterministic display for users with duplicate series numbers in their metadata). Fix: add a stable tiebreaker — when ni == nj and neither is missing, tiebreak on book ID: `entries[i].Book.ID < entries[j].Book.ID`. Guard the nil-Book case (ghost entries have Book=nil). --- [MINOR] internal/series/service.go:170 — TotalKnown set true whenever seriesTotal > 0, even when seriesTotal <= maxOwned `totalKnown := seriesTotal > 0` fires regardless of whether seriesTotal is actually used as the range ceiling (which only happens when seriesTotal > maxOwned). When a user owns books numbered past seriesTotal (e.g., own #1 and #5, seriesTotal=3), the template renders "own 2 of 3" while ghost cards show gaps through #5. The docstring at line 41 says totalKnown means "seriesTotal > 0 AND > max owned", but the code doesn't match. Fix: `totalKnown := seriesTotal > 0 && seriesTotal > maxOwned` (maxOwned is already available in buildSeriesDetail at that point). --- REVIEW VERDICT: 1 blocker, 1 major, 1 minor
Author
Owner

CODE REVIEW: NOT APPROVED

Phase 0: DEMO Verification

No runnable CLI DEMO block exists in the bead comments. The implementer posted two rendered screenshots as the visual proof. Screenshots were downloaded and inspected. The screenshots actually expose the BLOCKER rather than verifying correctness: the seeded scenario (owned #4 and #6 in an 8-book series) shows ghost cards only for #5, #7, #8 — entries #1, #2, #3 are entirely absent from both the grid and the book-list tab. The heading shows "OWN 2 OF 8" confirming series_total=8 is known, so gaps #1–#3 should be visible but are not.


Phase 1 & 2: Findings

[BLOCKER] internal/series/series.go:134-138 — computeMissingEntries starts at minOwned, not 1, when seriesTotal > maxOwned

The docstring at line 40 explicitly states the totalKnown strategy is "gaps in 1..seriesTotal". The implementation sets rangeMin = minOwned unconditionally (line 134) and only extends rangeMax to seriesTotal (lines 136–138). When seriesTotal > maxOwned, the loop runs minOwned..seriesTotal — so any entry before the first owned book is silently dropped.

Concrete example: series_total=8, owned={#4, #6}. Expected missing: #1,#2,#3,#5,#7,#8. Actual: #5,#7,#8. The screenshots confirm this exact bug — #1, #2, #3 are invisible. The test at line 340 ("returns interior gaps and tail when seriesTotal > maxOwned") uses ownedNumbers=[1,3] where minOwned=1, so it cannot catch this.

Fix: when seriesTotal > maxOwned, set rangeMin = 1 instead of minOwned. Also add a test: ownedNumbers=[3,5], seriesTotal=7 → expected missing [1,2,4,6,7].


[MAJOR] internal/series/service.go:243-250 — buildSeriesEntries sort comparator violates strict-weak-ordering for two owned books at the same series_number

The comparator at line 248: return !entries[i].Missing. When both i and j are owned books (Missing=false) at the same series_number, less(i,j) = true AND less(j,i) = true — both claim to be less than each other. This violates antisymmetry. Go's sort.SliceStable won't panic but produces undefined ordering for that pair on every page render (non-deterministic display for users with duplicate series numbers in their metadata).

Fix: add a stable tiebreaker — when ni == nj and neither is missing, tiebreak on book ID: entries[i].Book.ID < entries[j].Book.ID. Guard the nil-Book case (ghost entries have Book=nil).


[MINOR] internal/series/service.go:170 — TotalKnown set true whenever seriesTotal > 0, even when seriesTotal <= maxOwned

totalKnown := seriesTotal > 0 fires regardless of whether seriesTotal is actually used as the range ceiling (which only happens when seriesTotal > maxOwned). When a user owns books numbered past seriesTotal (e.g., own #1 and #5, seriesTotal=3), the template renders "own 2 of 3" while ghost cards show gaps through #5. The docstring at line 41 says totalKnown means "seriesTotal > 0 AND > max owned", but the code doesn't match.

Fix: totalKnown := seriesTotal > 0 && seriesTotal > maxOwned (maxOwned is already available in buildSeriesDetail at that point).


REVIEW VERDICT: 1 blocker, 1 major, 1 minor

## CODE REVIEW: NOT APPROVED ### Phase 0: DEMO Verification No runnable CLI DEMO block exists in the bead comments. The implementer posted two rendered screenshots as the visual proof. Screenshots were downloaded and inspected. The screenshots actually **expose the BLOCKER** rather than verifying correctness: the seeded scenario (owned #4 and #6 in an 8-book series) shows ghost cards only for #5, #7, #8 — entries #1, #2, #3 are entirely absent from both the grid and the book-list tab. The heading shows "OWN 2 OF 8" confirming series_total=8 is known, so gaps #1–#3 should be visible but are not. --- ### Phase 1 & 2: Findings [BLOCKER] internal/series/series.go:134-138 — computeMissingEntries starts at minOwned, not 1, when seriesTotal > maxOwned The docstring at line 40 explicitly states the totalKnown strategy is "gaps in 1..seriesTotal". The implementation sets rangeMin = minOwned unconditionally (line 134) and only extends rangeMax to seriesTotal (lines 136–138). When seriesTotal > maxOwned, the loop runs minOwned..seriesTotal — so any entry before the first owned book is silently dropped. Concrete example: series_total=8, owned={#4, #6}. Expected missing: #1,#2,#3,#5,#7,#8. Actual: #5,#7,#8. The screenshots confirm this exact bug — #1, #2, #3 are invisible. The test at line 340 ("returns interior gaps and tail when seriesTotal > maxOwned") uses ownedNumbers=[1,3] where minOwned=1, so it cannot catch this. Fix: when seriesTotal > maxOwned, set rangeMin = 1 instead of minOwned. Also add a test: ownedNumbers=[3,5], seriesTotal=7 → expected missing [1,2,4,6,7]. --- [MAJOR] internal/series/service.go:243-250 — buildSeriesEntries sort comparator violates strict-weak-ordering for two owned books at the same series_number The comparator at line 248: `return !entries[i].Missing`. When both i and j are owned books (Missing=false) at the same series_number, less(i,j) = true AND less(j,i) = true — both claim to be less than each other. This violates antisymmetry. Go's sort.SliceStable won't panic but produces undefined ordering for that pair on every page render (non-deterministic display for users with duplicate series numbers in their metadata). Fix: add a stable tiebreaker — when ni == nj and neither is missing, tiebreak on book ID: `entries[i].Book.ID < entries[j].Book.ID`. Guard the nil-Book case (ghost entries have Book=nil). --- [MINOR] internal/series/service.go:170 — TotalKnown set true whenever seriesTotal > 0, even when seriesTotal <= maxOwned `totalKnown := seriesTotal > 0` fires regardless of whether seriesTotal is actually used as the range ceiling (which only happens when seriesTotal > maxOwned). When a user owns books numbered past seriesTotal (e.g., own #1 and #5, seriesTotal=3), the template renders "own 2 of 3" while ghost cards show gaps through #5. The docstring at line 41 says totalKnown means "seriesTotal > 0 AND > max owned", but the code doesn't match. Fix: `totalKnown := seriesTotal > 0 && seriesTotal > maxOwned` (maxOwned is already available in buildSeriesDetail at that point). --- REVIEW VERDICT: 1 blocker, 1 major, 1 minor
Author
Owner

Re-screenshot: leading gaps fix verified

Series detail for "The Wheel of Time" — owned books at positions #4 and #6 out of 8 total.

Before fix: ghost cards would only show #5, #7, #8 (interior + tail). Leading gaps #1, #2, #3 were silently dropped.
After fix: ghost cards correctly show #1, #2, #3 (leading) + #5 (interior) + #7, #8 (trailing) — OWN 2 OF 8.

series_leading_gaps

## Re-screenshot: leading gaps fix verified Series detail for "The Wheel of Time" — owned books at positions #4 and #6 out of 8 total. **Before fix:** ghost cards would only show #5, #7, #8 (interior + tail). Leading gaps #1, #2, #3 were silently dropped. **After fix:** ghost cards correctly show #1, #2, #3 (leading) + #5 (interior) + #7, #8 (trailing) — OWN 2 OF 8. ![series_leading_gaps](https://git.zombor.net/attachments/3b24c03b-2b06-4b55-b066-8d1fa7a8640b)
fix(series): apply review fixes — leading gaps, fail-closed data leak, DoS cap, sort total order (bookshelf-4ztq.1)
All checks were successful
/ JS Unit Tests (pull_request) Successful in 38s
/ E2E API (pull_request) Successful in 2m31s
/ E2E Browser (pull_request) Successful in 2m30s
/ Lint (pull_request) Successful in 2m57s
/ Integration (pull_request) Successful in 3m44s
/ Test (pull_request) Successful in 4m39s
c71a9e08e6
BLOCKER fix — series.go:computeMissingEntries: set rangeMin=1 when seriesTotal>maxOwned
so leading gaps (entries below the first owned book) are included in the output.
Previously rangeMin=minOwned unconditionally, silently dropping all gaps before the
first owned entry.

MAJOR-1 fix — store.go:BooksInSeries: add fail-closed guard (nil && len==0 → return nil)
before the library filter branch, matching the pattern at lines 77-80 and 211.
An empty non-nil userLibraryIDs now returns zero rows instead of all series books.

MAJOR-2 fix — series.go:computeMissingEntries: cap output at maxMissingEntries=500
to prevent OOM when series_total is a large user-supplied value.

MAJOR-3 fix — service.go:buildSeriesEntries: replace non-total-order comparator with
a strict total order: tiebreak on Missing flag difference first, then on book ID for
two owned books at the same series_number, then return false for equal ghosts.

MINOR-1 fix — service.go:buildSeriesDetail: totalKnown := seriesTotal>0 && seriesTotal>maxOwned,
aligning with computeMissingEntries strategy and the docstring.

Tests: added leading-gap cases (owned=[3,5]/total=7 and owned=[4,6]/total=8),
DoS-cap assertion, BooksInSeries empty-slice fail-closed, sort tiebreak, and
ghost-ghost stability. All 269 specs pass. Coverage: 100% on series pkg (wire.go excluded).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
chore: ignore series_leading_gaps.png screenshot artifact (bookshelf-4ztq.1)
All checks were successful
/ JS Unit Tests (pull_request) Successful in 36s
/ Lint (pull_request) Successful in 1m52s
/ E2E API (pull_request) Successful in 1m57s
/ E2E Browser (pull_request) Successful in 2m57s
/ Integration (pull_request) Successful in 3m2s
/ Test (pull_request) Successful in 4m11s
7520518a8a
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
zombor force-pushed bd-bookshelf-4ztq.1 from 7520518a8a
All checks were successful
/ JS Unit Tests (pull_request) Successful in 36s
/ Lint (pull_request) Successful in 1m52s
/ E2E API (pull_request) Successful in 1m57s
/ E2E Browser (pull_request) Successful in 2m57s
/ Integration (pull_request) Successful in 3m2s
/ Test (pull_request) Successful in 4m11s
to 30231f68f4
All checks were successful
/ JS Unit Tests (pull_request) Successful in 52s
/ Lint (pull_request) Successful in 2m50s
/ E2E API (pull_request) Successful in 2m22s
/ Integration (pull_request) Successful in 3m24s
/ Test (pull_request) Successful in 4m7s
/ E2E Browser (pull_request) Successful in 3m36s
2026-06-22 17:30:44 +00:00
Compare
chore: remove committed series-gaps screenshot tool (bookshelf-4ztq.1)
All checks were successful
/ JS Unit Tests (pull_request) Successful in 56s
/ Lint (pull_request) Successful in 2m49s
/ E2E API (pull_request) Successful in 2m21s
/ E2E Browser (pull_request) Successful in 3m7s
/ Integration (pull_request) Successful in 3m34s
/ Test (pull_request) Successful in 4m11s
408352f245
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
zombor merged commit c7bd76e8b7 into main 2026-06-22 17:50:07 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
zombor/pergamum!694
No description provided.