feat(books): match-confidence + cross-provider identity guard on bulk enrich (bookshelf-iso0) #885

Merged
zombor merged 4 commits from bd-bookshelf-iso0 into main 2026-07-03 03:21:04 +00:00
Owner

Summary

  • Adds EbookIdentityConfidence and ComicIdentityConfidence scoring functions (Jaccard word-set similarity, ISBN exact-match, comic issue hard-fail, year proximity bonus).
  • Adds ProvidersAgree cross-provider identity agreement check — on disagreement, reduces allResults to winner-only so the merge doesn't blend data from providers that identified different books.
  • Wires applyConfidenceGate into RefetchMetadata, scoped to the bulk unattended path only (enabledOverride != nil); the interactive single-book path is unchanged.
  • ErrLowConfidenceMatch is a permanent sentinel — the wfengine adapter maps it to workflow.NewPermanentError so the bulk sweep skips the book without burning retry budget.
  • Adds two Prometheus counters: metadata_refetch_low_confidence_skipped_total{type} and metadata_refetch_provider_disagreement_total.
  • 100% coverage; all existing tests pass.

Test plan

  • make test — all 3912+ specs pass
  • make coverage — 100% coverage gate passes
  • make build — binary compiles
  • go vet ./... + golangci-lint run ./internal/books/... — 0 issues
  • New tests: low-confidence comic skip, issue-mismatch skip, high-confidence pass, interactive path bypass, provider disagreement winner-only, ebook low-confidence skip, cross-provider agreement ISBN10
  • ErrLowConfidenceMatch is defined in internal/books with no workflow-engine import

Closes bead bookshelf-iso0 on merge.

## Summary - Adds `EbookIdentityConfidence` and `ComicIdentityConfidence` scoring functions (Jaccard word-set similarity, ISBN exact-match, comic issue hard-fail, year proximity bonus). - Adds `ProvidersAgree` cross-provider identity agreement check — on disagreement, reduces `allResults` to winner-only so the merge doesn't blend data from providers that identified different books. - Wires `applyConfidenceGate` into `RefetchMetadata`, scoped to the bulk unattended path only (`enabledOverride != nil`); the interactive single-book path is unchanged. - `ErrLowConfidenceMatch` is a permanent sentinel — the wfengine adapter maps it to `workflow.NewPermanentError` so the bulk sweep skips the book without burning retry budget. - Adds two Prometheus counters: `metadata_refetch_low_confidence_skipped_total{type}` and `metadata_refetch_provider_disagreement_total`. - 100% coverage; all existing tests pass. ## Test plan - [x] `make test` — all 3912+ specs pass - [x] `make coverage` — 100% coverage gate passes - [x] `make build` — binary compiles - [x] `go vet ./...` + `golangci-lint run ./internal/books/...` — 0 issues - [x] New tests: low-confidence comic skip, issue-mismatch skip, high-confidence pass, interactive path bypass, provider disagreement winner-only, ebook low-confidence skip, cross-provider agreement ISBN10 - [x] `ErrLowConfidenceMatch` is defined in `internal/books` with no workflow-engine import Closes bead bookshelf-iso0 on merge.
feat(books): add match-confidence gate to bulk enrich path (bookshelf-iso0)
Some checks failed
/ JS Unit Tests (pull_request) Successful in 1m19s
/ E2E API (pull_request) Failing after 3m32s
/ Lint (pull_request) Successful in 3m41s
/ Integration (pull_request) Successful in 3m47s
/ Test (pull_request) Successful in 4m35s
/ E2E Browser (pull_request) Failing after 4m38s
d6983eba7f
- Adds `EbookIdentityConfidence` and `ComicIdentityConfidence` to score
  how well a provider's result matches the book's known identity (ISBN
  exact-match, Jaccard title/series similarity, issue number hard-fail,
  year proximity bonus).
- Adds `ProvidersAgree` to detect cross-provider disagreement on book
  identity; on disagreement, reduce allResults to winner-only so
  MergeMetadataByFieldPriority does not blend data from providers that
  identified different books.
- Wires `applyConfidenceGate` into `RefetchMetadata` scoped to the bulk
  path only (`enabledOverride != nil`); interactive single-book path is
  unchanged.
- `ErrLowConfidenceMatch` is a permanent sentinel (wfengine maps it to
  `workflow.NewPermanentError` — no retries on a bad fuzzy match).
- Adds `metadata_refetch_low_confidence_skipped_total` (with type label)
  and `metadata_refetch_provider_disagreement_total` Prometheus counters.
- 100% coverage; all existing tests pass.

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

Workflow Detail page screenshot (wf-detail-older-execution)

Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent.

wf-detail-older-execution

**Workflow Detail page screenshot** (wf-detail-older-execution) Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent. ![wf-detail-older-execution](/attachments/0dd9bfb0-d5e1-4015-af73-144437be0a7a)

Recompute Match Score — kebab open screenshot (recompute-match-score-kebab-open)

recompute-match-score-kebab-open

**Recompute Match Score — kebab open screenshot** (recompute-match-score-kebab-open) ![recompute-match-score-kebab-open](/attachments/398332f4-b2cb-4b2d-9c39-e14308cc9454)
zombor force-pushed bd-bookshelf-iso0 from d6983eba7f
Some checks failed
/ JS Unit Tests (pull_request) Successful in 1m19s
/ E2E API (pull_request) Failing after 3m32s
/ Lint (pull_request) Successful in 3m41s
/ Integration (pull_request) Successful in 3m47s
/ Test (pull_request) Successful in 4m35s
/ E2E Browser (pull_request) Failing after 4m38s
to a0c54cf855
All checks were successful
/ E2E API (pull_request) Successful in 2m22s
/ Lint (pull_request) Successful in 3m21s
/ Integration (pull_request) Successful in 3m25s
/ JS Unit Tests (pull_request) Successful in 1m5s
/ E2E Browser (pull_request) Successful in 3m51s
/ Test (pull_request) Successful in 4m20s
2026-07-02 19:51:17 +00:00
Compare

Recompute Match Score — kebab open screenshot (recompute-match-score-kebab-open)

recompute-match-score-kebab-open

**Recompute Match Score — kebab open screenshot** (recompute-match-score-kebab-open) ![recompute-match-score-kebab-open](/attachments/184886ad-0c8f-4e7a-8897-b9dbada63c92)

Workflow Detail page screenshot (wf-detail-older-execution)

Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent.

wf-detail-older-execution

**Workflow Detail page screenshot** (wf-detail-older-execution) Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent. ![wf-detail-older-execution](/attachments/86c963ab-e5e8-4c7c-96a6-393714b2cd9c)
zombor force-pushed bd-bookshelf-iso0 from a0c54cf855
All checks were successful
/ E2E API (pull_request) Successful in 2m22s
/ Lint (pull_request) Successful in 3m21s
/ Integration (pull_request) Successful in 3m25s
/ JS Unit Tests (pull_request) Successful in 1m5s
/ E2E Browser (pull_request) Successful in 3m51s
/ Test (pull_request) Successful in 4m20s
to 8f5f312185
All checks were successful
/ JS Unit Tests (pull_request) Successful in 35s
/ E2E API (pull_request) Successful in 2m13s
/ Lint (pull_request) Successful in 3m16s
/ Integration (pull_request) Successful in 3m18s
/ E2E Browser (pull_request) Successful in 3m56s
/ Test (pull_request) Successful in 4m8s
2026-07-03 00:23:40 +00:00
Compare
zombor changed target branch from bd-bookshelf-vnu3 to main 2026-07-03 00:25:27 +00:00

Recompute Match Score — kebab open screenshot (recompute-match-score-kebab-open)

recompute-match-score-kebab-open

**Recompute Match Score — kebab open screenshot** (recompute-match-score-kebab-open) ![recompute-match-score-kebab-open](/attachments/56457093-66e9-476c-988f-894c28c05294)

Workflow Detail page screenshot (wf-detail-older-execution)

Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent.

wf-detail-older-execution

**Workflow Detail page screenshot** (wf-detail-older-execution) Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent. ![wf-detail-older-execution](/attachments/99500e43-a0e7-4b87-94f5-f8c82bb17a94)
Author
Owner

Security Review — bookshelf-iso0 (PR #885)

Scope: enrich_confidence.go (confidence/identity scoring), Prometheus metrics, RefetchMetadata hook, tests.


[MAJOR] internal/wfengine/simple_workflows.go:150-154 — ErrLowConfidenceMatch missing from isPermanentEnrichErr

enrich_confidence.go documents the new sentinel explicitly: "This is a permanent error — the same low-quality match will occur on every retry... The wfengine activity adapter wraps this with workflow.NewPermanentError so the bulk sweep skips the book rather than burning retry budget." However, isPermanentEnrichErr in the wfengine adapter is NOT updated in this diff and does not include books.ErrLowConfidenceMatch. When the confidence gate fires and returns this error, the EnrichActivities.Refetch activity will classify it as a transient failure and retry it up to 5 times per enrich5Attempts (30s initial interval, 2× backoff, 10m cap) — burning approximately 15+ minutes of retry budget per rejected book. Under the project's single-digit serial fan-out concurrency cap, every low-confidence book in a bulk sweep blocks the queue for that entire backoff chain before finally failing. This directly violates the "Permanent errors must fail fast (no retry)" hard rule from project-conventions.md.

Fix: add errors.Is(err, books.ErrLowConfidenceMatch) to isPermanentEnrichErr in internal/wfengine/simple_workflows.go and add it to the sentinel inventory comment above that function.


[MINOR] internal/books/enrich_confidence_test.go:408-476 and confidence gate Describe blocks — test convention deviation

Pure-function tests (EbookIdentityConfidence, ComicIdentityConfidence, ProvidersAgree) place both setup and SUT invocation inline inside It blocks rather than using the project's BeforeEach (construct world) + JustBeforeEach (call SUT) split. The confidence gate integration tests (e.g. lines 917-946) bundle stub construction together with the SUT call in JustBeforeEach rather than putting stub construction in BeforeEach. No correctness or security impact for the current tests, but the pattern blocks future nested Context overrides and deviates from the stated project convention.

Fix: move fixture/stub construction to BeforeEach and leave only the refetch(...) call (capturing _, err = refetch(...)) in JustBeforeEach. For single-It pure-function tests, a JustBeforeEach holding just the SUT call and a dedicated It for the assertion is the correct form.


Other checks — all clean:

  • Untrusted input (provider title/series/author/ISBN/issue): used as inert string data for comparison only (Jaccard token similarity, string equality). No SQL built from provider data, no path construction, no HTML rendering.
  • Prometheus label cardinality: type label on lowConfidenceSkippedTotal is bounded to {"comic","ebook"} — hardcoded in applyConfidenceGate, never derived from provider-returned strings. providerDisagreementTotal is a plain label-free counter.
  • Multi-user: no per-user data path introduced. Gate is book-scoped, bulk path only. No new endpoints.
  • Architecture boundary: internal/books/enrich_confidence.go imports only stdlib + internal/metadata + internal/middleware. No workflow engine import. ErrLowConfidenceMatch is a plain sentinel error as required.
  • Log hygiene: disagreement/skip logs emit book_id, book_type, confidence, threshold, winner_provider, trace_id — no secrets, tokens, or PII.
  • Test package declarations: both new test files declare package books_test (black-box). ✓

REVIEW VERDICT: 0 blocker, 1 major, 1 minor

## Security Review — bookshelf-iso0 (PR #885) **Scope:** enrich_confidence.go (confidence/identity scoring), Prometheus metrics, RefetchMetadata hook, tests. --- [MAJOR] internal/wfengine/simple_workflows.go:150-154 — ErrLowConfidenceMatch missing from isPermanentEnrichErr `enrich_confidence.go` documents the new sentinel explicitly: "This is a permanent error — the same low-quality match will occur on every retry... The wfengine activity adapter wraps this with workflow.NewPermanentError so the bulk sweep skips the book rather than burning retry budget." However, `isPermanentEnrichErr` in the wfengine adapter is NOT updated in this diff and does not include `books.ErrLowConfidenceMatch`. When the confidence gate fires and returns this error, the `EnrichActivities.Refetch` activity will classify it as a transient failure and retry it up to 5 times per `enrich5Attempts` (30s initial interval, 2× backoff, 10m cap) — burning approximately 15+ minutes of retry budget per rejected book. Under the project's single-digit serial fan-out concurrency cap, every low-confidence book in a bulk sweep blocks the queue for that entire backoff chain before finally failing. This directly violates the "Permanent errors must fail fast (no retry)" hard rule from project-conventions.md. Fix: add `errors.Is(err, books.ErrLowConfidenceMatch)` to `isPermanentEnrichErr` in `internal/wfengine/simple_workflows.go` and add it to the sentinel inventory comment above that function. --- [MINOR] internal/books/enrich_confidence_test.go:408-476 and confidence gate Describe blocks — test convention deviation Pure-function tests (EbookIdentityConfidence, ComicIdentityConfidence, ProvidersAgree) place both setup and SUT invocation inline inside `It` blocks rather than using the project's `BeforeEach` (construct world) + `JustBeforeEach` (call SUT) split. The confidence gate integration tests (e.g. lines 917-946) bundle stub construction together with the SUT call in `JustBeforeEach` rather than putting stub construction in `BeforeEach`. No correctness or security impact for the current tests, but the pattern blocks future nested `Context` overrides and deviates from the stated project convention. Fix: move fixture/stub construction to `BeforeEach` and leave only the `refetch(...)` call (capturing `_, err = refetch(...)`) in `JustBeforeEach`. For single-It pure-function tests, a `JustBeforeEach` holding just the SUT call and a dedicated `It` for the assertion is the correct form. --- **Other checks — all clean:** - Untrusted input (provider title/series/author/ISBN/issue): used as inert string data for comparison only (Jaccard token similarity, string equality). No SQL built from provider data, no path construction, no HTML rendering. - Prometheus label cardinality: `type` label on `lowConfidenceSkippedTotal` is bounded to {"comic","ebook"} — hardcoded in `applyConfidenceGate`, never derived from provider-returned strings. `providerDisagreementTotal` is a plain label-free counter. - Multi-user: no per-user data path introduced. Gate is book-scoped, bulk path only. No new endpoints. - Architecture boundary: `internal/books/enrich_confidence.go` imports only stdlib + `internal/metadata` + `internal/middleware`. No workflow engine import. `ErrLowConfidenceMatch` is a plain sentinel error as required. - Log hygiene: disagreement/skip logs emit `book_id`, `book_type`, `confidence`, `threshold`, `winner_provider`, `trace_id` — no secrets, tokens, or PII. - Test package declarations: both new test files declare `package books_test` (black-box). ✓ --- REVIEW VERDICT: 0 blocker, 1 major, 1 minor
Author
Owner

CODE REVIEW — bookshelf-iso0 (PR #885)

Head: 8f5f312185


[BLOCKER] internal/wfengine/simple_workflows.go:150 — ErrLowConfidenceMatch missing from isPermanentEnrichErr
The PR's core stated goal is to prevent wasted retry budget on low-confidence books. The sentinel ErrLowConfidenceMatch is documented in enrich_confidence.go:87-88 as being wrapped with workflow.NewPermanentError by the wfengine adapter — but isPermanentEnrichErr at simple_workflows.go:150-155 was never updated to include errors.Is(err, books.ErrLowConfidenceMatch). The comment in that function says "NOTE: this is the ONLY place in the codebase that maps metadata domain errors to the go-workflows permanent-error concept." Without this, low-confidence books burn MaxAttempts × backoff on every bulk sweep, which is exactly the failure mode this feature exists to prevent. Fix: add errors.Is(err, books.ErrLowConfidenceMatch) to the isPermanentEnrichErr conjunction, and add a test case in simple_workflows_test.go alongside the existing ErrForbiddenQuery case.

[MAJOR] internal/books/enrich_confidence.go:196 — issue number hard-fail uses raw string equality without normalization
ComicIdentityConfidence hard-fails to 0.0 when q.IssueNumber != c.Comic.IssueNumber using plain string equality. The ComicVine provider already has a normalizeIssueNumber function that strips leading zeros and normalizes floats ("018"→"18", "7.0"→"7"), but that normalization is NOT applied before comparing the book's stored issue number against the provider's result. Real-world sources for q.IssueNumber include user-edited fields (e.g. "#7"), sidecar comicinfo.xml (format varies), and formatSeriesNumber — any of which may produce a format different from what ComicVine returns. A stored "#7" vs a provider's "7" hard-fails to 0.0, silently stopping enrichment for that book on every bulk sweep (once the BLOCKER above is fixed). Same problem in comicProvidersAgree at line 275. Fix: normalize both q.IssueNumber and c.Comic.IssueNumber with a shared normalization function (strip leading "#", apply parseFloat/leading-zero stripping) before string comparison.

[MAJOR] internal/books/enrich_confidence.go:250 — map iteration nondeterminism in reference-provider selection
Both comicProvidersAgree (line 250-286) and ebookProvidersAgree (line 291-331) iterate results map[string]metadata.Metadata to pick a reference provider. Go map iteration order is randomized per-run. For exactly 2 providers (the current typical case: ComicVine+Metron, Google Books+Open Library) the comparison is pairwise-symmetric so the result is stable. But for 3+ providers the choice of reference affects the outcome — "does A agree with B and C?" can differ from "does B agree with A and C?" when the metrics are not transitive. This is the exact nondeterminism pattern flagged in review-standard.md as [MAJOR]. Fix: sort the provider keys before iterating (e.g. slices.Sorted(maps.Keys(results))) to guarantee a deterministic reference every time.

[MINOR] internal/books/enrich_confidence.go:171 — empty-title guard appears after similarity computation
The check if q.Title == "" && c.Title == "" { return 0.0 } on line 171 appears after titleSim and authorSim are already computed. This is correct in behavior (the guard correctly overrides the vacuous 1.0 scores) but the unintuitive ordering causes a reader to think the function returns 1.0 for two empty titles. Suggest moving the guard to the top of the fuzzy-score section, before titleSim is computed.

[MINOR] internal/books/enrich_confidence.go:147 — ISBN check paths are dead code at the primary call site
The ISBN exact-match (lines 147-151) and ISBN-disagreement (lines 155-159) early returns inside EbookIdentityConfidence are dead code when the function is called from applyConfidenceGate, because the gate only fires when q.ISBN13 == "" && q.ISBN10 == "". The function is exported so external callers could theoretically reach these paths, but no such caller exists in the diff. The doc comment should clarify that ISBN checks are vacuous when called from the bulk gate path, or the ISBN paths should be validated with a direct unit test that calls the function with a query that has ISBN.


REVIEW VERDICT: 1 blocker, 2 major, 2 minor

## CODE REVIEW — bookshelf-iso0 (PR #885) **Head:** 8f5f312185f2 --- [BLOCKER] internal/wfengine/simple_workflows.go:150 — ErrLowConfidenceMatch missing from isPermanentEnrichErr The PR's core stated goal is to prevent wasted retry budget on low-confidence books. The sentinel `ErrLowConfidenceMatch` is documented in `enrich_confidence.go:87-88` as being wrapped with `workflow.NewPermanentError` by the wfengine adapter — but `isPermanentEnrichErr` at `simple_workflows.go:150-155` was never updated to include `errors.Is(err, books.ErrLowConfidenceMatch)`. The comment in that function says "NOTE: this is the ONLY place in the codebase that maps metadata domain errors to the go-workflows permanent-error concept." Without this, low-confidence books burn MaxAttempts × backoff on every bulk sweep, which is exactly the failure mode this feature exists to prevent. Fix: add `errors.Is(err, books.ErrLowConfidenceMatch)` to the `isPermanentEnrichErr` conjunction, and add a test case in `simple_workflows_test.go` alongside the existing `ErrForbiddenQuery` case. [MAJOR] internal/books/enrich_confidence.go:196 — issue number hard-fail uses raw string equality without normalization `ComicIdentityConfidence` hard-fails to 0.0 when `q.IssueNumber != c.Comic.IssueNumber` using plain string equality. The ComicVine provider already has a `normalizeIssueNumber` function that strips leading zeros and normalizes floats ("018"→"18", "7.0"→"7"), but that normalization is NOT applied before comparing the book's stored issue number against the provider's result. Real-world sources for `q.IssueNumber` include user-edited fields (e.g. "#7"), sidecar comicinfo.xml (format varies), and `formatSeriesNumber` — any of which may produce a format different from what ComicVine returns. A stored "#7" vs a provider's "7" hard-fails to 0.0, silently stopping enrichment for that book on every bulk sweep (once the BLOCKER above is fixed). Same problem in `comicProvidersAgree` at line 275. Fix: normalize both `q.IssueNumber` and `c.Comic.IssueNumber` with a shared normalization function (strip leading "#", apply `parseFloat`/leading-zero stripping) before string comparison. [MAJOR] internal/books/enrich_confidence.go:250 — map iteration nondeterminism in reference-provider selection Both `comicProvidersAgree` (line 250-286) and `ebookProvidersAgree` (line 291-331) iterate `results map[string]metadata.Metadata` to pick a reference provider. Go map iteration order is randomized per-run. For exactly 2 providers (the current typical case: ComicVine+Metron, Google Books+Open Library) the comparison is pairwise-symmetric so the result is stable. But for 3+ providers the choice of reference affects the outcome — "does A agree with B and C?" can differ from "does B agree with A and C?" when the metrics are not transitive. This is the exact nondeterminism pattern flagged in review-standard.md as [MAJOR]. Fix: sort the provider keys before iterating (e.g. `slices.Sorted(maps.Keys(results))`) to guarantee a deterministic reference every time. [MINOR] internal/books/enrich_confidence.go:171 — empty-title guard appears after similarity computation The check `if q.Title == "" && c.Title == "" { return 0.0 }` on line 171 appears after `titleSim` and `authorSim` are already computed. This is correct in behavior (the guard correctly overrides the vacuous 1.0 scores) but the unintuitive ordering causes a reader to think the function returns 1.0 for two empty titles. Suggest moving the guard to the top of the fuzzy-score section, before `titleSim` is computed. [MINOR] internal/books/enrich_confidence.go:147 — ISBN check paths are dead code at the primary call site The ISBN exact-match (lines 147-151) and ISBN-disagreement (lines 155-159) early returns inside `EbookIdentityConfidence` are dead code when the function is called from `applyConfidenceGate`, because the gate only fires when `q.ISBN13 == "" && q.ISBN10 == ""`. The function is exported so external callers could theoretically reach these paths, but no such caller exists in the diff. The doc comment should clarify that ISBN checks are vacuous when called from the bulk gate path, or the ISBN paths should be validated with a direct unit test that calls the function with a query that has ISBN. --- REVIEW VERDICT: 1 blocker, 2 major, 2 minor
fix(books): address review findings on confidence gate (bookshelf-iso0)
Some checks failed
/ JS Unit Tests (pull_request) Successful in 36s
/ E2E API (pull_request) Successful in 2m47s
/ Lint (pull_request) Failing after 2m47s
/ Integration (pull_request) Successful in 4m15s
/ E2E Browser (pull_request) Failing after 4m26s
/ Test (pull_request) Successful in 5m10s
24f06cffbe
BLOCKER: add ErrLowConfidenceMatch to isPermanentEnrichErr so the
wfengine does not retry a low-confidence skip 5× at backoff.

MAJOR: extract normalizeIssueNumber to metadata.NormalizeIssueNumber
(adds '#' stripping; shared by comicvine + enrich_confidence). Issue
number comparisons in ComicIdentityConfidence and comicProvidersAgree
now normalize both sides before comparing so '#7'/'018'/'7.0' all
equal '7'.

MAJOR: sort provider keys before iterating in comicProvidersAgree and
ebookProvidersAgree for deterministic reference-provider selection.

MINOR: move empty-title guard above titleSim/authorSim computation in
EbookIdentityConfidence. Restructure confidence-gate integration tests
to proper BeforeEach (world)/JustBeforeEach (SUT call)/It pattern.

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

Recompute Match Score — kebab open screenshot (recompute-match-score-kebab-open)

recompute-match-score-kebab-open

**Recompute Match Score — kebab open screenshot** (recompute-match-score-kebab-open) ![recompute-match-score-kebab-open](/attachments/7e34f96e-016b-4fae-b133-787a31aabd2c)

Workflow Detail page screenshot (wf-detail-older-execution)

Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent.

wf-detail-older-execution

**Workflow Detail page screenshot** (wf-detail-older-execution) Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent. ![wf-detail-older-execution](/attachments/a4f2fd75-97da-482a-b9f2-df904dd3b312)
fix(metadata): use strings.TrimPrefix for leading '#' strip (staticcheck S1017)
Some checks failed
/ JS Unit Tests (pull_request) Successful in 34s
/ E2E API (pull_request) Successful in 2m5s
/ E2E Browser (pull_request) Failing after 2m53s
/ Lint (pull_request) Successful in 2m57s
/ Integration (pull_request) Successful in 3m1s
/ Test (pull_request) Successful in 3m53s
fbad29565f
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Workflow Detail page screenshot (wf-detail-older-execution)

Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent.

wf-detail-older-execution

**Workflow Detail page screenshot** (wf-detail-older-execution) Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent. ![wf-detail-older-execution](/attachments/442eb3f3-d49f-4241-a774-14d784eeba34)

Recompute Match Score — kebab open screenshot (recompute-match-score-kebab-open)

recompute-match-score-kebab-open

**Recompute Match Score — kebab open screenshot** (recompute-match-score-kebab-open) ![recompute-match-score-kebab-open](/attachments/ee7d855e-02db-4a84-b976-408287b442f7)
fix(e2e): move Publishers tab click to BeforeAll to prevent pageTimeout exhaustion
All checks were successful
/ JS Unit Tests (pull_request) Successful in 35s
/ E2E API (pull_request) Successful in 2m41s
/ Lint (pull_request) Successful in 4m0s
/ Integration (pull_request) Successful in 4m10s
/ E2E Browser (pull_request) Successful in 4m17s
/ Test (pull_request) Successful in 5m1s
5d3b07ef09
JustBeforeEach ran navigate+WaitStable+waitForStimulusController before
*each* It step in the nested Ordered Describe, meaning the full
navigation cost was paid twice (once per It). Under CI load the combined
navigate+wait budget consumed the 60-second pageTimeout, leaving
MustElement no time and causing a context-deadline-exceeded panic.

Move the one-time navigation+click into BeforeAll (paid once) and add
refreshPageTimeout at the start of each It per the go-rod Ordered-journey
gotcha documented in CLAUDE.md.

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

Recompute Match Score — kebab open screenshot (recompute-match-score-kebab-open)

recompute-match-score-kebab-open

**Recompute Match Score — kebab open screenshot** (recompute-match-score-kebab-open) ![recompute-match-score-kebab-open](/attachments/3327ec3a-542d-45db-ae16-0c18aac6faa8)

Workflow Detail page screenshot (wf-detail-older-execution)

Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent.

wf-detail-older-execution

**Workflow Detail page screenshot** (wf-detail-older-execution) Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent. ![wf-detail-older-execution](/attachments/04677188-0421-43d7-a72e-3f095b47fc72)
Author
Owner

Security Review — bookshelf-iso0 (re-review, head 5d3b07ef)

Scope: confidence gate / identity guard for bulk enrich path; NormalizeIssueNumber helper; Prometheus skip/disagreement metrics.


No regex anywhere in the new code — tokenize uses a character-by-character unicode loop and NormalizeIssueNumber uses strconv/strings. No ReDoS risk.

Prometheus label cardinality — `lowConfidenceSkippedTotal` is labeled `type` and its value is hardcoded to either `"comic"` or `"ebook"` in applyConfidenceGate (enrich_confidence.go:44/48). `providerDisagreementTotal` has no labels. Neither metric accepts provider-returned strings as label values. Bounded, clean.

Architecture boundary — `internal/books` and `internal/metadata` contain no imports of the workflow engine. `ErrLowConfidenceMatch` is a plain `errors.New` sentinel; the mapping to `workflow.NewPermanentError` lives exclusively in `internal/wfengine/simple_workflows.go:isPermanentEnrichErr`. Boundary respected.

Log hygiene — both Warn log calls (enrich_confidence.go:53–75) emit only: book_id (int64), book_type ("comic"/"ebook"), confidence (float), threshold (float), trace_id, winner_provider (provider ID, operator-configured), provider_count (int). No provider-returned title/author/ISBN/series strings are logged. No credentials or PII.

Multi-user — the confidence gate is keyed on book_id (not user-scoped) and introduces no new per-user data paths. Gate applies only on the unattended bulk path (enabledOverride != nil check in metadata_service.go:978), not on the interactive single-book path. No cross-user leak.

SQL injection — confidence scoring and NormalizeIssueNumber are pure comparison/string-normalization logic with zero DB interaction. No SQL built from provider strings.


[MINOR] internal/metadata/normalize.go:25-29 — NormalizeIssueNumber converts Inf (parsed successfully by ParseFloat) to int64, producing an implementation-defined result
strconv.ParseFloat accepts the literal strings "Inf", "+Inf", "-Inf" and returns ±math.Inf with err==nil. The branch then executes int64(math.Inf(1)), which in Go is implementation-defined (on amd64: -9223372036854775808). NormalizeIssueNumber("Inf") therefore returns "-9223372036854775808" — a confusingly wrong normalization. Real comic issue numbers from providers will never be "Inf", but the input originates from untrusted provider responses. The fix is a guard before the int64 cast: if math.IsInf(f, 0) || math.IsNaN(f) { return n }. Note: the overflowing-large-number case ("99999…") is NOT affected because ParseFloat returns ErrRange (err != nil) for those, correctly falling through to the suffix-stripping path.


REVIEW VERDICT: 0 blocker, 0 major, 1 minor

## Security Review — bookshelf-iso0 (re-review, head 5d3b07ef) Scope: confidence gate / identity guard for bulk enrich path; NormalizeIssueNumber helper; Prometheus skip/disagreement metrics. --- **No regex anywhere in the new code** — tokenize uses a character-by-character unicode loop and NormalizeIssueNumber uses strconv/strings. No ReDoS risk. **Prometheus label cardinality** — \`lowConfidenceSkippedTotal\` is labeled \`type\` and its value is hardcoded to either \`"comic"\` or \`"ebook"\` in applyConfidenceGate (enrich_confidence.go:44/48). \`providerDisagreementTotal\` has no labels. Neither metric accepts provider-returned strings as label values. Bounded, clean. **Architecture boundary** — \`internal/books\` and \`internal/metadata\` contain no imports of the workflow engine. \`ErrLowConfidenceMatch\` is a plain \`errors.New\` sentinel; the mapping to \`workflow.NewPermanentError\` lives exclusively in \`internal/wfengine/simple_workflows.go:isPermanentEnrichErr\`. Boundary respected. **Log hygiene** — both Warn log calls (enrich_confidence.go:53–75) emit only: book_id (int64), book_type ("comic"/"ebook"), confidence (float), threshold (float), trace_id, winner_provider (provider ID, operator-configured), provider_count (int). No provider-returned title/author/ISBN/series strings are logged. No credentials or PII. **Multi-user** — the confidence gate is keyed on book_id (not user-scoped) and introduces no new per-user data paths. Gate applies only on the unattended bulk path (enabledOverride != nil check in metadata_service.go:978), not on the interactive single-book path. No cross-user leak. **SQL injection** — confidence scoring and NormalizeIssueNumber are pure comparison/string-normalization logic with zero DB interaction. No SQL built from provider strings. --- [MINOR] internal/metadata/normalize.go:25-29 — NormalizeIssueNumber converts Inf (parsed successfully by ParseFloat) to int64, producing an implementation-defined result strconv.ParseFloat accepts the literal strings "Inf", "+Inf", "-Inf" and returns ±math.Inf with err==nil. The branch then executes int64(math.Inf(1)), which in Go is implementation-defined (on amd64: -9223372036854775808). NormalizeIssueNumber("Inf") therefore returns "-9223372036854775808" — a confusingly wrong normalization. Real comic issue numbers from providers will never be "Inf", but the input originates from untrusted provider responses. The fix is a guard before the int64 cast: if math.IsInf(f, 0) || math.IsNaN(f) { return n }. Note: the overflowing-large-number case ("99999…") is NOT affected because ParseFloat returns ErrRange (err != nil) for those, correctly falling through to the suffix-stripping path. --- REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Author
Owner

CODE REVIEW (re-review): bookshelf-iso0 — confidence gate + NormalizeIssueNumber

Head: 5d3b07ef09. Reviewing only new code since prior review (enrich_confidence.go, normalize.go, comicvine refactor, wfengine mapping, metrics). Ignoring e2e/browser/journey_library_stats_test.go per instruction.


Phase 0: DEMO block

No DEMO block in this bead (confidence gate is an unattended bulk-path guard; a DEMO would require a full bulk-enrich run against a low-signal book). Proceeding on the basis that this is an internal guard with no user-facing demo surface — acceptable for this bead type.


Arch boundary (BLOCKER focus)

internal/books/enrich_confidence.go imports: context, errors, fmt, log/slog, maps, slices, strconv, strings, unicode, internal/metadata, internal/middleware. No workflow engine import. internal/metadata/normalize.go imports only stdlib. Architecture boundary is clean.

isPermanentEnrichErr in internal/wfengine/simple_workflows.go:154–159 now includes errors.Is(err, books.ErrLowConfidenceMatch). The wrapping chain is intact: applyConfidenceGate returns fmt.Errorf("book %d: %w", bookID, ErrLowConfidenceMatch) (uses %w), RefetchMetadata propagates it as-is at line 986, the wfengine Refetch activity calls isPermanentEnrichErr(err) on the unwrapped error before re-wrapping it. errors.Is unwraps correctly. ✓

The test at simple_workflows_test.go exercises fmt.Errorf("book 42: %w", books.ErrLowConfidenceMatch) — mirrors actual wrapping. ✓


NormalizeIssueNumber correctness

internal/metadata/normalize.go — the # stripping is a behavioural delta from comicvine's old normalizeIssueNumber (which did not strip #). In the structured comicvine path, parseTitleComponents captures the digits after # and never returns a #-prefixed issue number, so the three call-sites in provider.go (norm = metadata.NormalizeIssueNumber(issueNum) at lines 468, 492, 573) are behaviour-equivalent to the old function. The # stripping only activates for raw q.IssueNumber/c.Comic.IssueNumber in ComicIdentityConfidence and comicProvidersAgree, where stripping # is the correct and desired behaviour. No regression.

Edge cases: "#018"→"18", "7.0"→"7", "18.1"→"18.1", "Annual"→"Annual", ""→"", "0AU"→"0AU" all covered in normalize_test.go. ✓


ComicIdentityConfidence + ProvidersAgree

Determinism: Both comicProvidersAgree and ebookProvidersAgree iterate via slices.Sorted(maps.Keys(results)) — deterministic regardless of map allocation order. ✓

Bulk-path gate scoping: internal/books/metadata_service.go:978 — gate is inside if enabledOverride != nil { ... }. Interactive path (enabledOverride == nil) bypasses it. Test "RefetchMetadata confidence gate — not applied on single-book interactive path" explicitly exercises this. ✓

Score cap: ComicIdentityConfidence caps at 1.0 in the year-bonus block (if score > 1.0 { score = 1.0 }). Maximum path without cap: seriesSim=1.0×0.7 + issueBonus=0.3 + yearBonus=0.05 = 1.05, capped to 1.0. ✓

False-negative risk: When query has an issue number but candidate does not (normC == ""), the hard-fail does not fire and the bonus is withheld — the candidate must pass on series similarity alone (≥0.714 to reach 0.5 threshold after ×0.7). This is intentionally conservative and documented. Acceptable design choice, not a bug.


Metrics

lowConfidenceSkippedTotal label {"type"} with values "comic" or "ebook" — hardcoded in production code, not data-driven. Fixed cardinality (2 values). providerDisagreementTotal is a plain Counter with no labels. No attacker-controlled label values. ✓

registerRefetchCounter mirrors the pre-existing registerRefetchCounterVec pattern exactly (including the silent-drop on non-AlreadyRegisteredError errors — a pre-existing pattern, not new). ✓


Test quality

All new test files declare _test packages: package books_test, package metadata_test, package wfengine_test. No unexported symbols accessed directly. export_test.go bridge file (package wfengine) correctly exposes IsPermanentEnrichErr for black-box testing — standard Go pattern, not a white-box violation. Test calls in BeforeEach mirror the pre-existing pattern in refetch_metrics_test.go. ✓


Finding

[MINOR] internal/metadata/normalize.go:28 — theoretical int64 overflow for float-exponential issue strings
int64(f) where f is a valid float64 outside [math.MinInt64, math.MaxInt64] (e.g. input "1e308") produces undefined/saturated behaviour in Go. In practice comic issue numbers are always small integers, so this is unreachable in production. A guard if f > math.MaxInt64 || f < math.MinInt64 { return n } before the cast would make it formally correct.


REVIEW VERDICT: 0 blocker, 0 major, 1 minor

## CODE REVIEW (re-review): bookshelf-iso0 — confidence gate + NormalizeIssueNumber Head: 5d3b07ef09e2. Reviewing only new code since prior review (enrich_confidence.go, normalize.go, comicvine refactor, wfengine mapping, metrics). Ignoring e2e/browser/journey_library_stats_test.go per instruction. --- ### Phase 0: DEMO block No DEMO block in this bead (confidence gate is an unattended bulk-path guard; a DEMO would require a full bulk-enrich run against a low-signal book). Proceeding on the basis that this is an internal guard with no user-facing demo surface — acceptable for this bead type. --- ### Arch boundary (BLOCKER focus) `internal/books/enrich_confidence.go` imports: `context`, `errors`, `fmt`, `log/slog`, `maps`, `slices`, `strconv`, `strings`, `unicode`, `internal/metadata`, `internal/middleware`. **No workflow engine import.** `internal/metadata/normalize.go` imports only stdlib. Architecture boundary is clean. `isPermanentEnrichErr` in `internal/wfengine/simple_workflows.go:154–159` now includes `errors.Is(err, books.ErrLowConfidenceMatch)`. The wrapping chain is intact: `applyConfidenceGate` returns `fmt.Errorf("book %d: %w", bookID, ErrLowConfidenceMatch)` (uses `%w`), `RefetchMetadata` propagates it as-is at line 986, the wfengine `Refetch` activity calls `isPermanentEnrichErr(err)` on the unwrapped error before re-wrapping it. `errors.Is` unwraps correctly. ✓ The test at `simple_workflows_test.go` exercises `fmt.Errorf("book 42: %w", books.ErrLowConfidenceMatch)` — mirrors actual wrapping. ✓ --- ### NormalizeIssueNumber correctness `internal/metadata/normalize.go` — the `#` stripping is a behavioural delta from comicvine's old `normalizeIssueNumber` (which did not strip `#`). In the structured comicvine path, `parseTitleComponents` captures the digits *after* `#` and never returns a `#`-prefixed issue number, so the three call-sites in `provider.go` (`norm = metadata.NormalizeIssueNumber(issueNum)` at lines 468, 492, 573) are behaviour-equivalent to the old function. The `#` stripping only activates for raw `q.IssueNumber`/`c.Comic.IssueNumber` in `ComicIdentityConfidence` and `comicProvidersAgree`, where stripping `#` is the correct and desired behaviour. No regression. Edge cases: `"#018"→"18"`, `"7.0"→"7"`, `"18.1"→"18.1"`, `"Annual"→"Annual"`, `""→""`, `"0AU"→"0AU"` all covered in `normalize_test.go`. ✓ --- ### ComicIdentityConfidence + ProvidersAgree **Determinism:** Both `comicProvidersAgree` and `ebookProvidersAgree` iterate via `slices.Sorted(maps.Keys(results))` — deterministic regardless of map allocation order. ✓ **Bulk-path gate scoping:** `internal/books/metadata_service.go:978` — gate is inside `if enabledOverride != nil { ... }`. Interactive path (`enabledOverride == nil`) bypasses it. Test `"RefetchMetadata confidence gate — not applied on single-book interactive path"` explicitly exercises this. ✓ **Score cap:** `ComicIdentityConfidence` caps at 1.0 in the year-bonus block (`if score > 1.0 { score = 1.0 }`). Maximum path without cap: seriesSim=1.0×0.7 + issueBonus=0.3 + yearBonus=0.05 = 1.05, capped to 1.0. ✓ **False-negative risk:** When query has an issue number but candidate does not (`normC == ""`), the hard-fail does not fire and the bonus is withheld — the candidate must pass on series similarity alone (≥0.714 to reach 0.5 threshold after ×0.7). This is intentionally conservative and documented. Acceptable design choice, not a bug. --- ### Metrics `lowConfidenceSkippedTotal` label `{"type"}` with values `"comic"` or `"ebook"` — hardcoded in production code, not data-driven. Fixed cardinality (2 values). `providerDisagreementTotal` is a plain Counter with no labels. No attacker-controlled label values. ✓ `registerRefetchCounter` mirrors the pre-existing `registerRefetchCounterVec` pattern exactly (including the silent-drop on non-`AlreadyRegisteredError` errors — a pre-existing pattern, not new). ✓ --- ### Test quality All new test files declare `_test` packages: `package books_test`, `package metadata_test`, `package wfengine_test`. No unexported symbols accessed directly. `export_test.go` bridge file (`package wfengine`) correctly exposes `IsPermanentEnrichErr` for black-box testing — standard Go pattern, not a white-box violation. Test calls in `BeforeEach` mirror the pre-existing pattern in `refetch_metrics_test.go`. ✓ --- ### Finding [MINOR] internal/metadata/normalize.go:28 — theoretical `int64` overflow for float-exponential issue strings `int64(f)` where `f` is a valid `float64` outside `[math.MinInt64, math.MaxInt64]` (e.g. input `"1e308"`) produces undefined/saturated behaviour in Go. In practice comic issue numbers are always small integers, so this is unreachable in production. A guard `if f > math.MaxInt64 || f < math.MinInt64 { return n }` before the cast would make it formally correct. --- REVIEW VERDICT: 0 blocker, 0 major, 1 minor
zombor force-pushed bd-bookshelf-iso0 from 5d3b07ef09
All checks were successful
/ JS Unit Tests (pull_request) Successful in 35s
/ E2E API (pull_request) Successful in 2m41s
/ Lint (pull_request) Successful in 4m0s
/ Integration (pull_request) Successful in 4m10s
/ E2E Browser (pull_request) Successful in 4m17s
/ Test (pull_request) Successful in 5m1s
to 3b2fafd3f2
All checks were successful
/ JS Unit Tests (pull_request) Successful in 45s
/ E2E API (pull_request) Successful in 2m37s
/ Integration (pull_request) Successful in 3m50s
/ Lint (pull_request) Successful in 4m15s
/ E2E Browser (pull_request) Successful in 4m1s
/ Test (pull_request) Successful in 5m12s
2026-07-03 03:03:40 +00:00
Compare

Workflow Detail page screenshot (wf-detail-older-execution)

Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent.

wf-detail-older-execution

**Workflow Detail page screenshot** (wf-detail-older-execution) Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent. ![wf-detail-older-execution](/attachments/2d742993-732e-4e4a-9eac-c87ddc1623fb)

Recompute Match Score — kebab open screenshot (recompute-match-score-kebab-open)

recompute-match-score-kebab-open

**Recompute Match Score — kebab open screenshot** (recompute-match-score-kebab-open) ![recompute-match-score-kebab-open](/attachments/0b91942a-11c3-4452-a0be-366a63809626)
zombor force-pushed bd-bookshelf-iso0 from 3b2fafd3f2
All checks were successful
/ JS Unit Tests (pull_request) Successful in 45s
/ E2E API (pull_request) Successful in 2m37s
/ Integration (pull_request) Successful in 3m50s
/ Lint (pull_request) Successful in 4m15s
/ E2E Browser (pull_request) Successful in 4m1s
/ Test (pull_request) Successful in 5m12s
to fc6e8adfda
All checks were successful
/ JS Unit Tests (pull_request) Successful in 40s
/ E2E API (pull_request) Successful in 2m35s
/ Lint (pull_request) Successful in 4m0s
/ Integration (pull_request) Successful in 4m0s
/ E2E Browser (pull_request) Successful in 4m23s
/ Test (pull_request) Successful in 4m59s
2026-07-03 03:15:32 +00:00
Compare

Recompute Match Score — kebab open screenshot (recompute-match-score-kebab-open)

recompute-match-score-kebab-open

**Recompute Match Score — kebab open screenshot** (recompute-match-score-kebab-open) ![recompute-match-score-kebab-open](/attachments/33a90bcc-34a6-482c-b824-11c384b091cd)
zombor merged commit 73db0db0e8 into main 2026-07-03 03:21:04 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
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!885
No description provided.