feat(metadata): Recalculate Metadata Scores — on-demand library-wide recompute (bookshelf-r6lx.2) #583

Merged
zombor merged 8 commits from bd-bookshelf-r6lx.2 into main 2026-06-18 02:04:06 +00:00
Owner

Summary

  • Adds "Recalculate All Scores" button on /settings/match-weights; clicking POSTs to admin-gated POST /settings/match-weights/recalculate (202 Accepted)
  • New RecalcMatchScoresWorkflow uses ContinueAsNew pagination (100 books/epoch) + single-activity-per-page design — no per-book fan-out, bounded history for 250k-book libraries
  • GetWeightsForRecalc falls back to defaults silently on settings load error so a transient DB hiccup never blocks the sweep; deterministic instance ID "recalc-match-scores" ensures idempotency (double-click → 409)
  • Stimulus controller recalculate() method POSTs, handles 409 gracefully, appends "View Workflows" link on success

Test plan

  • Unit: internal/wfengine/recalc_scores_workflow_test.go — all 3 activities (success + error paths) + 4 workflow tester scenarios (empty library, single batch, weights fallback, batch error non-fatal) + Engine.StartRecalcMatchScoresWorkflow (success, not registered, already running)
  • Unit: internal/books/recompute_scores_test.go — happy path, ErrNotFound skip, transient error, setScore error, context cancellation
  • Unit: internal/settings/match_weights_handler_test.go — RecalcMatchScoresHandler: 202 success, nil dep → 503, ErrConflict propagation, generic error
  • JS unit: static/js/test/match_weights_settings_controller.test.js — RECALCULATE section: POST to recalculateUrl, server message shown, Workflows link appended, 409 handled without error styling, 503 shows error
  • E2E API: e2e/api/settings_match_weights_test.go — authenticated POST → 202 + instance_id + message; unauthenticated POST → 401
  • E2E browser: e2e/browser/settings_match_weights_test.go — authenticated click → status span non-empty + screenshot posted to PR

Closes bead bookshelf-r6lx.2 on merge.

## Summary - Adds **"Recalculate All Scores"** button on `/settings/match-weights`; clicking POSTs to admin-gated `POST /settings/match-weights/recalculate` (202 Accepted) - New `RecalcMatchScoresWorkflow` uses ContinueAsNew pagination (100 books/epoch) + single-activity-per-page design — no per-book fan-out, bounded history for 250k-book libraries - `GetWeightsForRecalc` falls back to defaults silently on settings load error so a transient DB hiccup never blocks the sweep; deterministic instance ID "recalc-match-scores" ensures idempotency (double-click → 409) - Stimulus controller `recalculate()` method POSTs, handles 409 gracefully, appends "View Workflows" link on success ## Test plan - [x] Unit: `internal/wfengine/recalc_scores_workflow_test.go` — all 3 activities (success + error paths) + 4 workflow tester scenarios (empty library, single batch, weights fallback, batch error non-fatal) + Engine.StartRecalcMatchScoresWorkflow (success, not registered, already running) - [x] Unit: `internal/books/recompute_scores_test.go` — happy path, ErrNotFound skip, transient error, setScore error, context cancellation - [x] Unit: `internal/settings/match_weights_handler_test.go` — RecalcMatchScoresHandler: 202 success, nil dep → 503, ErrConflict propagation, generic error - [x] JS unit: `static/js/test/match_weights_settings_controller.test.js` — RECALCULATE section: POST to recalculateUrl, server message shown, Workflows link appended, 409 handled without error styling, 503 shows error - [x] E2E API: `e2e/api/settings_match_weights_test.go` — authenticated POST → 202 + instance_id + message; unauthenticated POST → 401 - [x] E2E browser: `e2e/browser/settings_match_weights_test.go` — authenticated click → status span non-empty + screenshot posted to PR Closes bead bookshelf-r6lx.2 on merge.
feat(metadata): Recalculate Metadata Scores — on-demand library-wide recompute with current weights (bookshelf-r6lx.2)
Some checks failed
/ JS Unit Tests (pull_request) Successful in 22s
/ Lint (pull_request) Successful in 2m37s
/ E2E Browser (pull_request) Successful in 2m55s
/ E2E API (pull_request) Failing after 3m34s
/ Integration (pull_request) Has been cancelled
/ Test (pull_request) Has been cancelled
7dda94a310
Adds a "Recalculate All Scores" button on the Match Weights settings page that
triggers a background workflow recomputing book.metadata_match_score for the
entire library using the current configured weights. ContinueAsNew pagination
(100 books/epoch) bounds workflow history for 250k-book libraries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(e2e): add Accept header to unauthenticated recalculate POST test (bookshelf-r6lx.2)
Some checks failed
/ JS Unit Tests (pull_request) Successful in 18s
/ E2E Browser (pull_request) Successful in 3m6s
/ E2E API (pull_request) Successful in 5m25s
/ Lint (pull_request) Successful in 11m2s
/ Integration (pull_request) Failing after 11m56s
/ Test (pull_request) Has been cancelled
b4a34ab3f3
fix(test): add stopping condition to weights-fallback workflow tester test (bookshelf-r6lx.2)
Some checks failed
/ JS Unit Tests (pull_request) Successful in 17s
/ E2E API (pull_request) Failing after 10m53s
/ E2E Browser (pull_request) Failing after 10m54s
/ Lint (pull_request) Successful in 11m57s
/ Test (pull_request) Failing after 14m31s
/ Integration (pull_request) Failing after 15m45s
8bbc79ce24
The listBooksPage stub always returned [5] causing the workflow to
ContinueAsNew infinitely; second call now returns nil to terminate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
zombor force-pushed bd-bookshelf-r6lx.2 from 8bbc79ce24
Some checks failed
/ JS Unit Tests (pull_request) Successful in 17s
/ E2E API (pull_request) Failing after 10m53s
/ E2E Browser (pull_request) Failing after 10m54s
/ Lint (pull_request) Successful in 11m57s
/ Test (pull_request) Failing after 14m31s
/ Integration (pull_request) Failing after 15m45s
to 88f4128c36
All checks were successful
/ JS Unit Tests (pull_request) Successful in 55s
/ Lint (pull_request) Successful in 3m19s
/ Test (pull_request) Successful in 4m22s
/ E2E Browser (pull_request) Successful in 5m41s
/ Integration (pull_request) Successful in 7m21s
/ E2E API (pull_request) Successful in 8m21s
2026-06-16 17:58:50 +00:00
Compare
Author
Owner

Security Review — PR #583 (bookshelf-r6lx.2: Recalculate Metadata Scores)

Reviewed diff: origin/main...origin/bd-bookshelf-r6lx.2 (HEAD SHA 88f4128c). CI green — tests not re-run per review-standard.


Findings

[MINOR] e2e/api/settings_match_weights_test.go — no 403 test for authenticated non-admin user on POST /settings/match-weights/recalculate

The new e2e test suite covers the unauthenticated case (401) and the happy-path admin case (202), but does not exercise the authenticated non-admin path (403 Forbidden). The adminRequired guard is correctly applied in routes.go and exists for other settings routes, but the convention in e2e/api/libraries_rbac_test.go and books_rbac_test.go is to test the 403 response explicitly using SeedNonAdmin. The omission is a test-hygiene gap, not a correctness gap — the guard is inherited from adminRequired and cannot be bypassed — but it leaves an unexercised branch. Add a Context block that seeds a non-admin user, authenticates with AuthenticatedClientFor, and asserts http.StatusForbidden.


Threat-model walkthrough

AuthZ — admin gate on the recalculate endpoint: CONFIRMED SECURE. internal/settings/routes.go registers the route as adminRequired(eh.Wrap(RecalcMatchScoresHandler(mwd))) — identical guard to the existing PUT/DELETE match-weights routes. A non-admin authenticated session cannot reach the handler.

DoS / resource — overlapping recompute workflows: CONFIRMED SECURE. StartRecalcMatchScoresWorkflow uses a fixed deterministic instance ID "recalc-match-scores". A concurrent POST while the workflow is running hits ErrInstanceAlreadyExists from the go-workflows backend, which is mapped to ErrBulkAlreadyRunning → 409 Conflict at the HTTP layer. The JS controller shows a human-readable "already in progress" message without error styling. The workflow itself is cursor-paginated (recalcScoresBatchSize = 100, ContinueAsNew per epoch) with no unbounded fan-out: all scoring runs inline per epoch, satisfying the single-digit fan-out rule.

SQL injection — weights and book scan: CONFIRMED SECURE. ListBooksForScoreBackfill is a parameterised sqlc query with no string interpolation. SetBookMetadataMatchScore is similarly parameterised. No weight field is interpolated into SQL.

Multi-user / per-user data isolation: CONFIRMED CORRECT. metadata_match_score lives on book_metadata, which is per-book not per-user. The recompute is a global admin sweep by design. No per-user data is written; the scan does not touch user_book_progress or any user-scoped table.

CSRF on the POST: CONFIRMED PROTECTED. The Stimulus recalculate() method sends credentials: "same-origin" and "X-CSRF-Token": _csrfToken(), consistent with the existing save and resetToDefaults fetch calls. The CSRF middleware (internal/middleware/csrf.go) uses the double-submit cookie pattern and is applied globally.

Secrets in logs: CONFIRMED CLEAN. The handler logs trace_id and instance_id only. Instance IDs are stable workflow identifiers, not secrets. No tokens, keys, or PII are logged.

Grimmory schema compatibility — no new columns: CONFIRMED. No new migrations in the diff. metadata_match_score on book_metadata already existed (same count in main and branch). Architecture boundary is respected: internal/books/recompute_scores.go imports only middleware and settings — no go-workflows or wfengine symbols.

Error swallowing — GetWeightsForRecalc non-fatal fallback: This is an intentional, documented design choice (swallow settings-read error, fall back to defaults so a transient DB hiccup on settings doesn't abort a potentially hours-long library sweep). The trade-off is that a silent settings-layer failure causes the sweep to run with defaults without any operator-visible alert. This is not a security issue but may warrant a Warn-level log line rather than silently swallowing — the existing comment explains the intent, so leaving as-is is acceptable.

_ = err after GetWeightsForRecalc: The workflow discards the error from the activity call because GetWeightsForRecalc always returns nil (by design — errors are eaten internally). This is safe given the documented contract, but the _ = err on line 111 of recalc_scores_workflow.go is a minor readability concern as it looks like accidental error suppression to a new reader. The comment partially addresses it.


REVIEW VERDICT: 0 blocker, 0 major, 1 minor

## Security Review — PR #583 (bookshelf-r6lx.2: Recalculate Metadata Scores) Reviewed diff: `origin/main...origin/bd-bookshelf-r6lx.2` (HEAD SHA 88f4128c). CI green — tests not re-run per review-standard. --- ### Findings **[MINOR] e2e/api/settings_match_weights_test.go — no 403 test for authenticated non-admin user on POST /settings/match-weights/recalculate** The new e2e test suite covers the unauthenticated case (401) and the happy-path admin case (202), but does not exercise the authenticated non-admin path (403 Forbidden). The `adminRequired` guard is correctly applied in `routes.go` and exists for other settings routes, but the convention in `e2e/api/libraries_rbac_test.go` and `books_rbac_test.go` is to test the 403 response explicitly using `SeedNonAdmin`. The omission is a test-hygiene gap, not a correctness gap — the guard is inherited from `adminRequired` and cannot be bypassed — but it leaves an unexercised branch. Add a Context block that seeds a non-admin user, authenticates with `AuthenticatedClientFor`, and asserts `http.StatusForbidden`. --- ### Threat-model walkthrough **AuthZ — admin gate on the recalculate endpoint:** CONFIRMED SECURE. `internal/settings/routes.go` registers the route as `adminRequired(eh.Wrap(RecalcMatchScoresHandler(mwd)))` — identical guard to the existing PUT/DELETE match-weights routes. A non-admin authenticated session cannot reach the handler. **DoS / resource — overlapping recompute workflows:** CONFIRMED SECURE. `StartRecalcMatchScoresWorkflow` uses a fixed deterministic instance ID `"recalc-match-scores"`. A concurrent POST while the workflow is running hits `ErrInstanceAlreadyExists` from the go-workflows backend, which is mapped to `ErrBulkAlreadyRunning` → 409 Conflict at the HTTP layer. The JS controller shows a human-readable "already in progress" message without error styling. The workflow itself is cursor-paginated (`recalcScoresBatchSize = 100`, `ContinueAsNew` per epoch) with no unbounded fan-out: all scoring runs inline per epoch, satisfying the single-digit fan-out rule. **SQL injection — weights and book scan:** CONFIRMED SECURE. `ListBooksForScoreBackfill` is a parameterised sqlc query with no string interpolation. `SetBookMetadataMatchScore` is similarly parameterised. No weight field is interpolated into SQL. **Multi-user / per-user data isolation:** CONFIRMED CORRECT. `metadata_match_score` lives on `book_metadata`, which is per-book not per-user. The recompute is a global admin sweep by design. No per-user data is written; the scan does not touch `user_book_progress` or any user-scoped table. **CSRF on the POST:** CONFIRMED PROTECTED. The Stimulus `recalculate()` method sends `credentials: "same-origin"` and `"X-CSRF-Token": _csrfToken()`, consistent with the existing `save` and `resetToDefaults` fetch calls. The CSRF middleware (`internal/middleware/csrf.go`) uses the double-submit cookie pattern and is applied globally. **Secrets in logs:** CONFIRMED CLEAN. The handler logs `trace_id` and `instance_id` only. Instance IDs are stable workflow identifiers, not secrets. No tokens, keys, or PII are logged. **Grimmory schema compatibility — no new columns:** CONFIRMED. No new migrations in the diff. `metadata_match_score` on `book_metadata` already existed (same count in main and branch). Architecture boundary is respected: `internal/books/recompute_scores.go` imports only `middleware` and `settings` — no `go-workflows` or `wfengine` symbols. **Error swallowing — `GetWeightsForRecalc` non-fatal fallback:** This is an intentional, documented design choice (swallow settings-read error, fall back to defaults so a transient DB hiccup on settings doesn't abort a potentially hours-long library sweep). The trade-off is that a silent settings-layer failure causes the sweep to run with defaults without any operator-visible alert. This is not a security issue but may warrant a `Warn`-level log line rather than silently swallowing — the existing comment explains the intent, so leaving as-is is acceptable. **`_ = err` after `GetWeightsForRecalc`:** The workflow discards the error from the activity call because `GetWeightsForRecalc` always returns nil (by design — errors are eaten internally). This is safe given the documented contract, but the `_ = err` on line 111 of `recalc_scores_workflow.go` is a minor readability concern as it looks like accidental error suppression to a new reader. The comment partially addresses it. --- REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Author
Owner

r6lx2_match_weights_recalculate

r6lx2_match_weights_recalculate

**r6lx2_match_weights_recalculate** ![r6lx2_match_weights_recalculate](https://git.zombor.net/attachments/a2a44a5a-9a31-4de9-9388-84c58fbe6ad8)
Author
Owner

[MINOR] internal/wfengine/recalc_scores_workflow.go:68 — GetWeightsForRecalc swallows error without logging
The catch block returns settings.DefaultMatchWeights(), nil silently. Per logging-standard, every error catch must log with context before swallowing. The activity struct has no logger field and the workflow ctx gowf.Logger is not available in activities — so add a logger *slog.Logger field to RecalcMatchScoresActivities and inject it via NewRecalcMatchScoresActivitiesWithStubs / buildRecalcMatchScoresDeps, then log: logger.Warn("GetWeightsForRecalc: settings read error, falling back to defaults", "error", err). Without this, a persistent settings DB failure silently recomputes the entire library with wrong/default weights with no operator trace.

[MINOR] internal/wfengine/recalc_scores_workflow_test.go:272 — Three Expects in one It block
The It("uses default weights when loading fails") block has three Expect calls: workflowErr, usedWeights length, and usedWeights[0] value. Per project convention, one Expect per It. Split into three It blocks (or fold the two value assertions into Expect(usedWeights, workflowErr).To(Equal(...)) for the value + nil-error combined, and add a separate It for length). Same pattern appears in engine_new_test.go:561 (unregistered-workflow It block is fine — single Expect).

[MINOR] internal/books/recompute_scores_test.go:62 — Loop with multiple Expects in single It
The It("persists a non-negative score for each book") iterates calledScore and calls Expect(s).To(BeNumerically(">=", 0)) inside a range loop — effectively N Expects per It. Per convention, one Expect per It. Replace with Expect(calledScore).To(HaveEach(BeNumerically(">=", 0))) (single Gomega assertion over the slice).

REVIEW VERDICT: 0 blocker, 0 major, 3 minor

[MINOR] internal/wfengine/recalc_scores_workflow.go:68 — GetWeightsForRecalc swallows error without logging The catch block returns `settings.DefaultMatchWeights(), nil` silently. Per logging-standard, every error catch must log with context before swallowing. The activity struct has no logger field and the workflow ctx `gowf.Logger` is not available in activities — so add a `logger *slog.Logger` field to `RecalcMatchScoresActivities` and inject it via `NewRecalcMatchScoresActivitiesWithStubs` / `buildRecalcMatchScoresDeps`, then log: `logger.Warn("GetWeightsForRecalc: settings read error, falling back to defaults", "error", err)`. Without this, a persistent settings DB failure silently recomputes the entire library with wrong/default weights with no operator trace. [MINOR] internal/wfengine/recalc_scores_workflow_test.go:272 — Three Expects in one It block The It("uses default weights when loading fails") block has three `Expect` calls: `workflowErr`, `usedWeights` length, and `usedWeights[0]` value. Per project convention, one `Expect` per `It`. Split into three `It` blocks (or fold the two value assertions into `Expect(usedWeights, workflowErr).To(Equal(...))` for the value + nil-error combined, and add a separate `It` for length). Same pattern appears in `engine_new_test.go:561` (unregistered-workflow `It` block is fine — single Expect). [MINOR] internal/books/recompute_scores_test.go:62 — Loop with multiple Expects in single It The It("persists a non-negative score for each book") iterates `calledScore` and calls `Expect(s).To(BeNumerically(">=", 0))` inside a range loop — effectively N Expects per `It`. Per convention, one `Expect` per `It`. Replace with `Expect(calledScore).To(HaveEach(BeNumerically(">=", 0)))` (single Gomega assertion over the slice). REVIEW VERDICT: 0 blocker, 0 major, 3 minor
fix(r6lx.2): add activity-not-found integration test + review minor fixes
All checks were successful
/ JS Unit Tests (pull_request) Successful in 32s
/ Lint (pull_request) Successful in 1m30s
/ Test (pull_request) Successful in 3m8s
/ E2E Browser (pull_request) Successful in 3m47s
/ E2E API (pull_request) Successful in 4m58s
/ Integration (pull_request) Successful in 5m11s
9897431991
- Add RecalcMatchScoresWorkflow real-engine integration test (mirrors
  BulkLLMSweepWorkflow pattern) that would catch any future queue/registry
  mismatch; test passes green confirming registration is correct
- Add logger to RecalcMatchScoresActivities and Warn log when settings
  read fails in GetWeightsForRecalc (logging-standard)
- Wire logger through engine.go registration block
- Fix CSS: add .settings-recalculate { margin-bottom: 2rem } so Field
  Weights heading is not abutted by the recalculate button (no inline style=)
- Fix test: split 3-Expect "uses default weights" It into 3 separate Its
  (one Expect each, per project conventions)
- Fix test: replace per-book Expect loop with HaveEach in recompute_scores_test.go
- Add non-admin 403 e2e test for POST /settings/match-weights/recalculate
- Add NewRecalcMatchScoresActivitiesWithLogger export helper + logger-path test
  to cover the if a.logger != nil branch (100% coverage gate)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(recalc): add recalcEpochDwell sleep + multi-epoch integration test for ContinueAsNew race (bookshelf-r6lx.2)
All checks were successful
/ JS Unit Tests (pull_request) Successful in 31s
/ Test (pull_request) Successful in 3m10s
/ E2E Browser (pull_request) Successful in 4m12s
/ Integration (pull_request) Successful in 5m32s
/ E2E API (pull_request) Successful in 5m34s
/ Lint (pull_request) Successful in 11m59s
f4d0c42ae3
Root cause: RecalcMatchScoresWorkflow fires ContinueAsNew epochs at near-instant
speed (inline CPU/DB score work with no I/O wait). The MySQL backend uses READ
COMMITTED isolation in GetWorkflowTask; under rapid-fire continuations the next
worker poll can dequeue a continuation task before its WorkflowExecutionStarted
pending event is fully committed — producing "workflow RecalcMatchScoresWorkflow
not found" and terminating the workflow with an error instead of completing the
sweep.

Fix: add a 200ms deterministic dwell (gowf.Sleep) before each ContinueAsNew.
The timer is replay-safe and prevents microsecond-interval continuation chains.
LibraryScanWorkflow is immune because its per-epoch cover fan-out takes real
wall-clock seconds between epochs.

Tests:
- New multi-epoch integration test forces ≥3 real ContinueAsNew epochs with
  fast-returning stubs on a real MySQL backend. Asserts detail.Result == "null"
  (JSON nil = true success) to catch both "workflow not found" and
  "activity not found" race variants.
- New tester unit test covers the gowf.Sleep error path via ScheduleCallback
  cancel before the 200ms dwell fires.
- Fixed both integration test assertions from BeEmpty() to Equal("null"):
  a successful nil-error workflow serialises its result as "null", not "".
zombor force-pushed bd-bookshelf-r6lx.2 from f4d0c42ae3
All checks were successful
/ JS Unit Tests (pull_request) Successful in 31s
/ Test (pull_request) Successful in 3m10s
/ E2E Browser (pull_request) Successful in 4m12s
/ Integration (pull_request) Successful in 5m32s
/ E2E API (pull_request) Successful in 5m34s
/ Lint (pull_request) Successful in 11m59s
to 4433b91116
Some checks failed
/ Lint (pull_request) Successful in 49s
/ JS Unit Tests (pull_request) Successful in 4m22s
/ Test (pull_request) Successful in 14m6s
/ Integration (pull_request) Successful in 3m21s
/ E2E Browser (pull_request) Successful in 4m8s
/ E2E API (pull_request) Failing after 14m53s
2026-06-17 00:38:12 +00:00
Compare
zombor force-pushed bd-bookshelf-r6lx.2 from 4433b91116
Some checks failed
/ Lint (pull_request) Successful in 49s
/ JS Unit Tests (pull_request) Successful in 4m22s
/ Test (pull_request) Successful in 14m6s
/ Integration (pull_request) Successful in 3m21s
/ E2E Browser (pull_request) Successful in 4m8s
/ E2E API (pull_request) Failing after 14m53s
to 50667e7c39
All checks were successful
/ Lint (pull_request) Successful in 2m18s
/ Integration (pull_request) Successful in 3m18s
/ Test (pull_request) Successful in 3m32s
/ JS Unit Tests (pull_request) Successful in 45s
/ E2E Browser (pull_request) Successful in 4m33s
/ E2E API (pull_request) Successful in 7m0s
2026-06-17 04:13:21 +00:00
Compare
Author
Owner

Security Review — PR #583 (bd-bookshelf-r6lx.2)

Scope: internal/wfengine/diag_accessor.go tiebreaker fix, internal/wfengine/recalc_scores_workflow.go (new), multi-epoch integration test, handler + wiring + tests added since the prior r6lx review.


Checklist findings

1. Parameterized queries / SQL injection

Both modified diag_accessor.go queries bind instance_id via ? placeholder with db.QueryRowContext. The , id DESC tiebreaker is a static column reference — no string interpolation or user-supplied data in the ORDER BY clause. No injection surface.

ListBooksForScoreBackfill in buildRecalcMatchScoresDeps uses dbsqlc.ListBooksForScoreBackfillParams with typed fields bound by sqlc — clean.

2. Admin gate + CSRF

POST /settings/match-weights/recalculate is registered as adminRequired(eh.Wrap(RecalcMatchScoresHandler(mwd))) in internal/settings/routes.go. The e2e test explicitly verifies 401 for unauthenticated and 403 for a non-admin authenticated user. Consistent with every other settings-write endpoint on this mux.

CSRF: the project uses a SameSite-cookie-based CSRF pattern applied at the middleware level for all non-GET state-changing endpoints; the route lives inside that boundary.

3. Logging — secrets/PII

RecalcMatchScoresHandler logs two Info events: one on entry (trace_id only) and one on success (instance_id + trace_id). No user input, no book data, no secrets.

GetWeightsForRecalc Warn log: a.logger.Warn("recalc: settings read failed, using default weights", "error", err)err is a DB/settings error (e.g. "connection reset", "settings unavailable"), not a secret or PII field. Safe.

4. No new Grimmory DB columns

buildRecalcMatchScoresDeps calls q.ListBooksForScoreBackfill, q.GetBookMetadataForMatchScore, and q.SetBookMetadataMatchScore — all pre-existing sqlc-generated queries. No migrations, no new columns.

5. Single-flight / duplicate trigger

StartRecalcMatchScoresWorkflow uses the deterministic instance ID "recalc-match-scores" and maps gobackend.ErrInstanceAlreadyExists → ErrBulkAlreadyRunning → HTTP 409. The caller in build_extended_deps.go wraps that as middleware.ErrConflict. Double-clicks and concurrent admin requests are idempotent at the engine layer.

6. userID from request body

RecalcMatchScoresHandler takes no body at all (http.NewRequest(http.MethodPost, …, nil) in the e2e). No userID from request. The workflow is intentionally library-global (admin-only, no per-user scope). Correct for an admin sweep.

7. Bounded query / no unbounded scan

RecalcMatchScoresWorkflow paginates at recalcScoresBatchSize = 100 per epoch. ListBooksForScoreBackfill receives a typed Limit int32 parameter. No unbounded query.

8. Batch error handling

A RecomputeScoresBatch activity failure logs a Warn and calls ContinueAsNew (sweep continues). ListBooksPageForRecalc failure propagates and aborts the epoch (retried up to MaxAttempts: 3). GetWeightsForRecalc failure falls back to defaults — non-fatal, logged. All three paths are exercised by dedicated tests.

9. Multi-epoch integration test

Uses sync/atomic for recomputeCalls and pageCallN counters — no data race. Eventually with explicit timeout and poll interval (28s / 300ms). No wall-clock timing assertions in the assertion path (the timeout is a test-infrastructure bound, not an assertion). Clean.

10. No workflow engine import in domain packages

internal/books/recompute_scores.go imports only context, errors, fmt, log/slog, middleware, and settings. No go-workflows import. Architecture boundary preserved.
gowf.NewPermanentError appears only in the workflow-tester test (recalc_scores_workflow_test.go) as a test helper to force a permanent error in the tester harness — this is the wfengine test package, not a domain package. Clean.


Findings

No security issues identified.

REVIEW VERDICT: 0 blocker, 0 major, 0 minor

## Security Review — PR #583 (bd-bookshelf-r6lx.2) **Scope:** `internal/wfengine/diag_accessor.go` tiebreaker fix, `internal/wfengine/recalc_scores_workflow.go` (new), multi-epoch integration test, handler + wiring + tests added since the prior r6lx review. --- ### Checklist findings **1. Parameterized queries / SQL injection** Both modified `diag_accessor.go` queries bind `instance_id` via `?` placeholder with `db.QueryRowContext`. The `, id DESC` tiebreaker is a static column reference — no string interpolation or user-supplied data in the ORDER BY clause. No injection surface. `ListBooksForScoreBackfill` in `buildRecalcMatchScoresDeps` uses `dbsqlc.ListBooksForScoreBackfillParams` with typed fields bound by sqlc — clean. **2. Admin gate + CSRF** `POST /settings/match-weights/recalculate` is registered as `adminRequired(eh.Wrap(RecalcMatchScoresHandler(mwd)))` in `internal/settings/routes.go`. The e2e test explicitly verifies 401 for unauthenticated and 403 for a non-admin authenticated user. Consistent with every other settings-write endpoint on this mux. CSRF: the project uses a SameSite-cookie-based CSRF pattern applied at the middleware level for all non-GET state-changing endpoints; the route lives inside that boundary. **3. Logging — secrets/PII** `RecalcMatchScoresHandler` logs two `Info` events: one on entry (trace_id only) and one on success (instance_id + trace_id). No user input, no book data, no secrets. `GetWeightsForRecalc` `Warn` log: `a.logger.Warn("recalc: settings read failed, using default weights", "error", err)` — `err` is a DB/settings error (e.g. "connection reset", "settings unavailable"), not a secret or PII field. Safe. **4. No new Grimmory DB columns** `buildRecalcMatchScoresDeps` calls `q.ListBooksForScoreBackfill`, `q.GetBookMetadataForMatchScore`, and `q.SetBookMetadataMatchScore` — all pre-existing sqlc-generated queries. No migrations, no new columns. **5. Single-flight / duplicate trigger** `StartRecalcMatchScoresWorkflow` uses the deterministic instance ID `"recalc-match-scores"` and maps `gobackend.ErrInstanceAlreadyExists → ErrBulkAlreadyRunning → HTTP 409`. The caller in `build_extended_deps.go` wraps that as `middleware.ErrConflict`. Double-clicks and concurrent admin requests are idempotent at the engine layer. **6. userID from request body** `RecalcMatchScoresHandler` takes no body at all (`http.NewRequest(http.MethodPost, …, nil)` in the e2e). No userID from request. The workflow is intentionally library-global (admin-only, no per-user scope). Correct for an admin sweep. **7. Bounded query / no unbounded scan** `RecalcMatchScoresWorkflow` paginates at `recalcScoresBatchSize = 100` per epoch. `ListBooksForScoreBackfill` receives a typed `Limit int32` parameter. No unbounded query. **8. Batch error handling** A `RecomputeScoresBatch` activity failure logs a `Warn` and calls `ContinueAsNew` (sweep continues). `ListBooksPageForRecalc` failure propagates and aborts the epoch (retried up to `MaxAttempts: 3`). `GetWeightsForRecalc` failure falls back to defaults — non-fatal, logged. All three paths are exercised by dedicated tests. **9. Multi-epoch integration test** Uses `sync/atomic` for `recomputeCalls` and `pageCallN` counters — no data race. `Eventually` with explicit timeout and poll interval (28s / 300ms). No wall-clock timing assertions in the assertion path (the timeout is a test-infrastructure bound, not an assertion). Clean. **10. No workflow engine import in domain packages** `internal/books/recompute_scores.go` imports only `context`, `errors`, `fmt`, `log/slog`, `middleware`, and `settings`. No `go-workflows` import. Architecture boundary preserved. `gowf.NewPermanentError` appears only in the workflow-tester test (`recalc_scores_workflow_test.go`) as a test helper to force a permanent error in the `tester` harness — this is the wfengine test package, not a domain package. Clean. --- ### Findings No security issues identified. REVIEW VERDICT: 0 blocker, 0 major, 0 minor
Author
Owner

CODE REVIEW: APPROVED

Reviewing the unreviewed changes added since the earlier clean review (diag-tiebreaker fix, multi-epoch integration test, minor fixes).


Phase 0: DEMO Verification

No DEMO block was required for this incremental review (the feature's DEMO was in the prior review pass). The review spec covers 4 specific change sets only.


Phase 1: Spec Compliance

All four change sets land:

  1. Diag tiebreaker fixinternal/wfengine/diag_accessor.go: both makeInstanceRowQuery and makeInstanceStateQuery now use ORDER BY created_at DESC, id DESC LIMIT 1. Tiebreaker is correct: instances.id is BIGINT AUTO_INCREMENT PK, strictly monotonic, deterministically selects newest execution. Applied to both diag query paths. No leftover dwell code in recalc_scores_workflow.go (confirmed: the word "dwell" appears only in comments describing the reverted approach, not in any time.Sleep / gowf.Sleep call).

  2. Multi-epoch regression test + diag tie test — three new integration test suites in engine_integration_test.go: empty-library single-epoch, multi-epoch CAN with atomic recompute counter, and a raw DB seed test that directly exercises the tiebreaker by inserting 6 same-second executions and asserting the diag reads the newest.

  3. Review-minor fixesGetWeightsForRecalc Warn log on settings error (wired through engine.go logger injection), one-Expect-per-It splits in workflow tests, HaveEach replaces per-book loop.

  4. Security minor — non-admin 403 e2e added in e2e/api/settings_match_weights_test.go.

Architecture boundaries confirmed:

  • internal/books/recompute_scores.go imports middleware, settings, slog — no wfengine or go-workflows import.
  • internal/settings/ imports nothing from wfengine.
  • CSS .settings-recalculate { margin-bottom: 2rem } — no inline style= attribute.

Phase 2: Code Quality

Diag tiebreaker correctness: The fix is sound. id DESC provides a total order when created_at ties. The diag seed test is the strongest guard: it inserts rows with a hard-coded identical DATETIME value (2026-06-16 12:00:00) and asserts both GetInstanceState and GetWorkflowDetail.ExecutionID resolve to the newest (highest id) execution. That test will fail red if the tiebreaker is ever removed or the query is changed to a non-total order.

_ = err on GetWeightsForRecalc (recalc_scores_workflow.go:129): This is intentional and safe. GetWeightsForRecalc never returns a non-nil error by contract (it falls back to defaults and returns nil on settings failure). The comment above the call documents this. The activity test suite exercises the fallback path. No silent swallowing of a real error here.

recalcActivityOptions.RetryOptions.MaxAttempts: 3: This applies to ListBooksPageForRecalc and RecomputeScoresBatch. Both are transient-only failure paths at the activity boundary — bad-input (missing metadata) is handled inside RecomputeScoresBatch with a skip, not an error return, so there is no bad-input path that burns retry budget. Correct.

Multi-epoch test comment (engine_integration_test.go:423–434): The Describe block comment says "Without the recalcEpochDwell fix this test fails intermittently" and the timeout comment says "3 epochs × ~200ms dwell". Both references are stale — the actual fix is the diag tiebreaker, not a dwell timer. The test DOES correctly guard the right symptom (workflow result contains "not found"), but the explanation of WHY it would fail is misleading to future readers. The 30s timeout is also unnecessarily generous now that there is no dwell.

[MINOR] internal/wfengine/engine_integration_test.go:423–434 — stale "recalcEpochDwell" narrative in multi-epoch test comment
The comment attributes the "workflow not found" failure to the dwell timer, but the dwell was reverted; the actual fix is ORDER BY created_at DESC, id DESC in diag_accessor.go. The accompanying timeout comment ("3 epochs × ~200ms dwell ≈ 30s") is also stale — without any dwell the 30s budget is ~25s larger than needed. No functional impact, but a future reader diagnosing a similar failure will be sent in the wrong direction. Suggested fix: replace "Without the recalcEpochDwell fix" → "Without the id DESC diag tiebreaker fix" and update the timeout comment to reflect actual timing.

No other issues found.


REVIEW VERDICT: 0 blocker, 0 major, 1 minor

## CODE REVIEW: APPROVED Reviewing the unreviewed changes added since the earlier clean review (diag-tiebreaker fix, multi-epoch integration test, minor fixes). --- ### Phase 0: DEMO Verification No DEMO block was required for this incremental review (the feature's DEMO was in the prior review pass). The review spec covers 4 specific change sets only. --- ### Phase 1: Spec Compliance All four change sets land: 1. **Diag tiebreaker fix** — `internal/wfengine/diag_accessor.go`: both `makeInstanceRowQuery` and `makeInstanceStateQuery` now use `ORDER BY created_at DESC, id DESC LIMIT 1`. Tiebreaker is correct: `instances.id` is BIGINT AUTO_INCREMENT PK, strictly monotonic, deterministically selects newest execution. Applied to both diag query paths. No leftover dwell code in `recalc_scores_workflow.go` (confirmed: the word "dwell" appears only in comments describing the reverted approach, not in any `time.Sleep` / `gowf.Sleep` call). 2. **Multi-epoch regression test + diag tie test** — three new integration test suites in `engine_integration_test.go`: empty-library single-epoch, multi-epoch CAN with `atomic` recompute counter, and a raw DB seed test that directly exercises the tiebreaker by inserting 6 same-second executions and asserting the diag reads the newest. 3. **Review-minor fixes** — `GetWeightsForRecalc` Warn log on settings error (wired through `engine.go` logger injection), one-Expect-per-It splits in workflow tests, `HaveEach` replaces per-book loop. 4. **Security minor** — non-admin 403 e2e added in `e2e/api/settings_match_weights_test.go`. **Architecture boundaries confirmed:** - `internal/books/recompute_scores.go` imports `middleware`, `settings`, `slog` — no `wfengine` or `go-workflows` import. - `internal/settings/` imports nothing from `wfengine`. - CSS `.settings-recalculate { margin-bottom: 2rem }` — no inline `style=` attribute. --- ### Phase 2: Code Quality **Diag tiebreaker correctness:** The fix is sound. `id DESC` provides a total order when `created_at` ties. The diag seed test is the strongest guard: it inserts rows with a hard-coded identical `DATETIME` value (`2026-06-16 12:00:00`) and asserts both `GetInstanceState` and `GetWorkflowDetail.ExecutionID` resolve to the newest (highest `id`) execution. That test will fail red if the tiebreaker is ever removed or the query is changed to a non-total order. **`_ = err` on `GetWeightsForRecalc`** (`recalc_scores_workflow.go:129`): This is intentional and safe. `GetWeightsForRecalc` never returns a non-nil error by contract (it falls back to defaults and returns `nil` on settings failure). The comment above the call documents this. The activity test suite exercises the fallback path. No silent swallowing of a real error here. **`recalcActivityOptions.RetryOptions.MaxAttempts: 3`**: This applies to `ListBooksPageForRecalc` and `RecomputeScoresBatch`. Both are transient-only failure paths at the activity boundary — bad-input (missing metadata) is handled inside `RecomputeScoresBatch` with a skip, not an error return, so there is no bad-input path that burns retry budget. Correct. **Multi-epoch test comment** (`engine_integration_test.go:423–434`): The Describe block comment says "Without the recalcEpochDwell fix this test fails intermittently" and the timeout comment says "3 epochs × ~200ms dwell". Both references are stale — the actual fix is the diag tiebreaker, not a dwell timer. The test DOES correctly guard the right symptom (workflow result contains "not found"), but the explanation of WHY it would fail is misleading to future readers. The 30s timeout is also unnecessarily generous now that there is no dwell. [MINOR] `internal/wfengine/engine_integration_test.go:423–434` — stale "recalcEpochDwell" narrative in multi-epoch test comment The comment attributes the "workflow not found" failure to the dwell timer, but the dwell was reverted; the actual fix is `ORDER BY created_at DESC, id DESC` in `diag_accessor.go`. The accompanying timeout comment ("3 epochs × ~200ms dwell ≈ 30s") is also stale — without any dwell the 30s budget is ~25s larger than needed. No functional impact, but a future reader diagnosing a similar failure will be sent in the wrong direction. Suggested fix: replace "Without the recalcEpochDwell fix" → "Without the `id DESC` diag tiebreaker fix" and update the timeout comment to reflect actual timing. No other issues found. --- REVIEW VERDICT: 0 blocker, 0 major, 1 minor
zombor force-pushed bd-bookshelf-r6lx.2 from 50667e7c39
All checks were successful
/ Lint (pull_request) Successful in 2m18s
/ Integration (pull_request) Successful in 3m18s
/ Test (pull_request) Successful in 3m32s
/ JS Unit Tests (pull_request) Successful in 45s
/ E2E Browser (pull_request) Successful in 4m33s
/ E2E API (pull_request) Successful in 7m0s
to 342a380021
Some checks failed
/ JS Unit Tests (pull_request) Successful in 44s
/ E2E Browser (pull_request) Failing after 1m17s
/ E2E API (pull_request) Successful in 1m44s
/ Lint (pull_request) Successful in 2m31s
/ Integration (pull_request) Successful in 3m18s
/ Test (pull_request) Successful in 3m40s
2026-06-18 00:47:52 +00:00
Compare
zombor force-pushed bd-bookshelf-r6lx.2 from 342a380021
Some checks failed
/ JS Unit Tests (pull_request) Successful in 44s
/ E2E Browser (pull_request) Failing after 1m17s
/ E2E API (pull_request) Successful in 1m44s
/ Lint (pull_request) Successful in 2m31s
/ Integration (pull_request) Successful in 3m18s
/ Test (pull_request) Successful in 3m40s
to 156fb76c49
Some checks failed
/ JS Unit Tests (pull_request) Successful in 33s
/ E2E API (pull_request) Successful in 1m36s
/ Lint (pull_request) Successful in 2m23s
/ Integration (pull_request) Successful in 2m58s
/ E2E Browser (pull_request) Successful in 3m1s
/ Test (pull_request) Failing after 3m10s
2026-06-18 01:04:22 +00:00
Compare
zombor force-pushed bd-bookshelf-r6lx.2 from 156fb76c49
Some checks failed
/ JS Unit Tests (pull_request) Successful in 33s
/ E2E API (pull_request) Successful in 1m36s
/ Lint (pull_request) Successful in 2m23s
/ Integration (pull_request) Successful in 2m58s
/ E2E Browser (pull_request) Successful in 3m1s
/ Test (pull_request) Failing after 3m10s
to ee8fa474fa
All checks were successful
/ JS Unit Tests (pull_request) Successful in 32s
/ E2E API (pull_request) Successful in 1m7s
/ Lint (pull_request) Successful in 1m15s
/ E2E Browser (pull_request) Successful in 2m12s
/ Integration (pull_request) Successful in 2m16s
/ Test (pull_request) Successful in 2m23s
2026-06-18 01:33:08 +00:00
Compare
Author
Owner

Security Review — PR #583 (bd-bookshelf-r6lx.2)

Reviewed the current diff (post-rebase). Scope: authorization on the recalc trigger, multi-user scoping, resource/DoS, input validation, secrets/PII logging, and CSP.


Findings

No security issues found.

Authorization (BLOCKER check):
POST /settings/match-weights/recalculate is registered at internal/settings/routes.go:35 with adminRequired(...) — same guard wrapping every other settings mutation route. The e2e test suite (e2e/api/settings_match_weights_test.go) explicitly exercises unauthenticated → 401 and non-admin authenticated → 403 cases against the real app. Auth is clean.

Multi-user scoping (MAJOR check):
match_score / metadata_match_score is a per-book global metadata field (not per-user data). The ListBooksForScoreBackfill query (internal/db/queries/books.sql:216) correctly selects from book with no user_id scoping — appropriate for an admin-only global recompute. No per-user data is read or written. Design is documented in build_extended_deps.go as "No library scoping — this is a global admin sweep."

Resource / DoS (MAJOR check):
The workflow uses a deterministic instance ID "recalc-match-scores" (engine.go:1012) so duplicate triggers (double-click, repeated POST) hit ErrInstanceAlreadyExistsErrBulkAlreadyRunning → HTTP 409, not a new workflow spawn. Fan-out is cursor-paginated in single-batch-per-epoch ContinueAsNew (batch size 100, no concurrent sub-workflows) — consistent with the bounded-fan-out hard rule. No unbounded resource exposure.

Input validation:
The handler accepts no request body payload — the POST has no JSON/form inputs to validate. The only data flowing in is from context (auth session, trace ID). Clean boundary.

Secrets/PII logging:
Log fields are trace_id and instance_id only. instance_id is the hard-coded string "recalc-match-scores" — no user data, no secrets.

CSP / inline styles/handlers:
Template changes (settings_match_weights.html) use only data-action Stimulus bindings — no onclick=, no onload=, no style= attributes. CSS change is a new rule in main.css (class-based). JS additions use textContent assignments (not innerHTML) and a hard-coded href="/admin/workflows" anchor. No CSP violations.

audit.Record placement:
Called after StartRecalcMatchScores returns without error (line 176), before w.WriteHeader — correct success-path-only placement, consistent with other settings handlers.


REVIEW VERDICT: 0 blocker, 0 major, 0 minor

## Security Review — PR #583 (bd-bookshelf-r6lx.2) Reviewed the current diff (post-rebase). Scope: authorization on the recalc trigger, multi-user scoping, resource/DoS, input validation, secrets/PII logging, and CSP. --- ### Findings **No security issues found.** **Authorization (BLOCKER check):** `POST /settings/match-weights/recalculate` is registered at `internal/settings/routes.go:35` with `adminRequired(...)` — same guard wrapping every other settings mutation route. The e2e test suite (`e2e/api/settings_match_weights_test.go`) explicitly exercises unauthenticated → 401 and non-admin authenticated → 403 cases against the real app. Auth is clean. **Multi-user scoping (MAJOR check):** `match_score` / `metadata_match_score` is a per-book global metadata field (not per-user data). The `ListBooksForScoreBackfill` query (`internal/db/queries/books.sql:216`) correctly selects from `book` with no `user_id` scoping — appropriate for an admin-only global recompute. No per-user data is read or written. Design is documented in `build_extended_deps.go` as "No library scoping — this is a global admin sweep." **Resource / DoS (MAJOR check):** The workflow uses a deterministic instance ID `"recalc-match-scores"` (engine.go:1012) so duplicate triggers (double-click, repeated POST) hit `ErrInstanceAlreadyExists` → `ErrBulkAlreadyRunning` → HTTP 409, not a new workflow spawn. Fan-out is cursor-paginated in single-batch-per-epoch ContinueAsNew (batch size 100, no concurrent sub-workflows) — consistent with the bounded-fan-out hard rule. No unbounded resource exposure. **Input validation:** The handler accepts no request body payload — the POST has no JSON/form inputs to validate. The only data flowing in is from `context` (auth session, trace ID). Clean boundary. **Secrets/PII logging:** Log fields are `trace_id` and `instance_id` only. `instance_id` is the hard-coded string `"recalc-match-scores"` — no user data, no secrets. **CSP / inline styles/handlers:** Template changes (`settings_match_weights.html`) use only `data-action` Stimulus bindings — no `onclick=`, no `onload=`, no `style=` attributes. CSS change is a new rule in `main.css` (class-based). JS additions use `textContent` assignments (not `innerHTML`) and a hard-coded `href="/admin/workflows"` anchor. No CSP violations. **audit.Record placement:** Called after `StartRecalcMatchScores` returns without error (line 176), before `w.WriteHeader` — correct success-path-only placement, consistent with other settings handlers. --- REVIEW VERDICT: 0 blocker, 0 major, 0 minor
Author
Owner

CODE REVIEW: APPROVED

Branch: bd-bookshelf-r6lx.2 — "Recalculate Metadata Scores" workflow + UI

CI: green (all 6 jobs). Diff reviewed fresh (~1766 lines).


Phase 0: HARD RULE checks

1. Workflow integration test (≥3 ContinueAsNew epochs + assert SUCCESS value)

PASS. engine_integration_test.go "multi-epoch ContinueAsNew" test drives exactly numEpochs=3 real epochs on a live engine, waits for completed, and asserts detail.Result == "null" (true success — go-workflows serialises a nil-error result as JSON null). The second It in the same Describe additionally asserts atomic.LoadInt64(&recomputeCalls) == int64(numEpochs). Both its have independent BeforeEach setups (fresh DB + engine). The diag-tiebreaker test seeds 6 same-second tied executions and asserts GetInstanceState resolves to "running" (not the stale completed epoch).

2. Permanent vs retryable error classification

PASS. internal/books/recompute_scores.go imports nothing from the workflow engine — it returns plain errors and middleware.ErrNotFound triggers a skip (not an error return). The wfengine adapter layer (recalc_scores_workflow.go) does not wrap domain errors with NewPermanentError; the batch-fail path is intentionally non-fatal at the workflow level (warn + ContinueAsNew). gowf.NewPermanentError in the test file (recalc_scores_workflow_test.go:354) is in the test stub only (simulating what would be a permanent fail for tester efficiency), not in production code.

3. Bounded fan-out

PASS. No per-book sub-workflow. The workflow batches 100 books per epoch into one RecomputeScoresBatch activity. ContinueAsNew bounds history. Scale rule is satisfied.

4. Diag tiebreaker

PASS. Both makeInstanceRowQuery (diag_accessor.go:129) and makeInstanceStateQuery (diag_accessor.go:144) use ORDER BY created_at DESC, id DESC LIMIT 1. instances.id is a BIGINT AUTO_INCREMENT primary key — strictly monotonic. Both queries updated consistently.

5. Multi-user / auth scoping

PASS. The endpoint is admin-gated via adminRequired(...) in settings/routes.go:35. The e2e test covers unauthenticated (401) and non-admin authenticated (403) cases. No user ID comes from the request body. The recalc is a global admin operation (not per-user data) — correct.

6. CSP — no inline style=

PASS. The new template block uses only classes (btn btn-secondary, settings-actions, settings-save-status, settings-recalculate). The CSS adds .settings-recalculate { margin-bottom: 2rem; }. No style= attributes. JS controller uses className assignment (class strings), not element.style. Clean.

7. Vitest controller test

PASS. Covers: POST fetch call and URL, server message display, Workflows link appended on success, 409 "already in progress" (no error class), 503 non-ok error styling. Good coverage of all controller branches.


Phase 1: Spec Compliance

All spec items from the bead description are present: workflow (ContinueAsNew + bounded batch), engine wiring, appwire trigger, handler + route, template button + status area, Stimulus controller, tests (workflow tester, integration, domain unit, handler, e2e API + browser Vitest).


Phase 2: Code Quality

Findings:

[MINOR] internal/wfengine/recalc_scores_workflow.go:128_ = err silently swallows the return from ExecuteActivity[MatchWeights].Get(ctx). The comment documents the intent (GetWeightsForRecalc always returns nil error), but if ctx is cancelled between activities, Get() returns a context error that is silently swallowed, and weights remains its zero value (MatchWeights{}). The next activity would also fail with ctx error and the workflow would terminate there, so this is not a correctness bug, but it is a latent footgun. Preferred fix: if err != nil { return fmt.Errorf("...get weights: %w", err) } — GetWeightsForRecalc already handles the non-fatal fallback internally; the activity-level error returned by .Get() is a different path (ctx cancel, framework error).

[MINOR] internal/wfengine/engine_integration_test.go:449-450 — The test timeout comment is stale: "3 epochs × ~200ms dwell + DB round-trips + 20s margin ≈ 30s total." There is no recalcEpochDwell in the workflow (per the recalc_scores_workflow.go:18-29 comment, the dwell was deliberately removed). The 30s timeout is still generous and correct, but the arithmetic is wrong. The comment should say the dwell is absent and the margin covers DB round-trips only.

[MINOR] internal/wfengine/engine_integration_test.go — The two It blocks in "multi-epoch ContinueAsNew" each start the workflow directly inside It (not via JustBeforeEach), mixing invocation and assertion. This breaks the project's Ginkgo BeforeEach / JustBeforeEach / It convention. Acceptable for an integration test given complexity, but worth noting.


Verdict

REVIEW VERDICT: 0 blocker, 0 major, 3 minor

## CODE REVIEW: APPROVED **Branch:** `bd-bookshelf-r6lx.2` — "Recalculate Metadata Scores" workflow + UI CI: green (all 6 jobs). Diff reviewed fresh (~1766 lines). --- ### Phase 0: HARD RULE checks **1. Workflow integration test (≥3 ContinueAsNew epochs + assert SUCCESS value)** PASS. `engine_integration_test.go` "multi-epoch ContinueAsNew" test drives exactly `numEpochs=3` real epochs on a live engine, waits for `completed`, and asserts `detail.Result == "null"` (true success — go-workflows serialises a nil-error result as JSON null). The second `It` in the same `Describe` additionally asserts `atomic.LoadInt64(&recomputeCalls) == int64(numEpochs)`. Both its have independent BeforeEach setups (fresh DB + engine). The diag-tiebreaker test seeds 6 same-second tied executions and asserts `GetInstanceState` resolves to `"running"` (not the stale completed epoch). **2. Permanent vs retryable error classification** PASS. `internal/books/recompute_scores.go` imports nothing from the workflow engine — it returns plain errors and `middleware.ErrNotFound` triggers a skip (not an error return). The wfengine adapter layer (`recalc_scores_workflow.go`) does not wrap domain errors with `NewPermanentError`; the batch-fail path is intentionally non-fatal at the workflow level (warn + ContinueAsNew). `gowf.NewPermanentError` in the test file (`recalc_scores_workflow_test.go:354`) is in the test stub only (simulating what would be a permanent fail for tester efficiency), not in production code. **3. Bounded fan-out** PASS. No per-book sub-workflow. The workflow batches 100 books per epoch into one `RecomputeScoresBatch` activity. ContinueAsNew bounds history. Scale rule is satisfied. **4. Diag tiebreaker** PASS. Both `makeInstanceRowQuery` (`diag_accessor.go:129`) and `makeInstanceStateQuery` (`diag_accessor.go:144`) use `ORDER BY created_at DESC, id DESC LIMIT 1`. `instances.id` is a `BIGINT AUTO_INCREMENT` primary key — strictly monotonic. Both queries updated consistently. **5. Multi-user / auth scoping** PASS. The endpoint is admin-gated via `adminRequired(...)` in `settings/routes.go:35`. The e2e test covers unauthenticated (401) and non-admin authenticated (403) cases. No user ID comes from the request body. The recalc is a global admin operation (not per-user data) — correct. **6. CSP — no inline `style=`** PASS. The new template block uses only classes (`btn btn-secondary`, `settings-actions`, `settings-save-status`, `settings-recalculate`). The CSS adds `.settings-recalculate { margin-bottom: 2rem; }`. No `style=` attributes. JS controller uses `className` assignment (class strings), not `element.style`. Clean. **7. Vitest controller test** PASS. Covers: POST fetch call and URL, server message display, Workflows link appended on success, 409 "already in progress" (no error class), 503 non-ok error styling. Good coverage of all controller branches. --- ### Phase 1: Spec Compliance All spec items from the bead description are present: workflow (ContinueAsNew + bounded batch), engine wiring, appwire trigger, handler + route, template button + status area, Stimulus controller, tests (workflow tester, integration, domain unit, handler, e2e API + browser Vitest). --- ### Phase 2: Code Quality **Findings:** [MINOR] `internal/wfengine/recalc_scores_workflow.go:128` — `_ = err` silently swallows the return from `ExecuteActivity[MatchWeights].Get(ctx)`. The comment documents the intent (GetWeightsForRecalc always returns nil error), but if `ctx` is cancelled between activities, `Get()` returns a context error that is silently swallowed, and `weights` remains its zero value (`MatchWeights{}`). The next activity would also fail with ctx error and the workflow would terminate there, so this is not a correctness bug, but it is a latent footgun. Preferred fix: `if err != nil { return fmt.Errorf("...get weights: %w", err) }` — GetWeightsForRecalc already handles the non-fatal fallback internally; the activity-level error returned by `.Get()` is a different path (ctx cancel, framework error). [MINOR] `internal/wfengine/engine_integration_test.go:449-450` — The test timeout comment is stale: "3 epochs × ~200ms dwell + DB round-trips + 20s margin ≈ 30s total." There is no `recalcEpochDwell` in the workflow (per the `recalc_scores_workflow.go:18-29` comment, the dwell was deliberately removed). The 30s timeout is still generous and correct, but the arithmetic is wrong. The comment should say the dwell is absent and the margin covers DB round-trips only. [MINOR] `internal/wfengine/engine_integration_test.go` — The two `It` blocks in "multi-epoch ContinueAsNew" each start the workflow directly inside `It` (not via `JustBeforeEach`), mixing invocation and assertion. This breaks the project's Ginkgo `BeforeEach / JustBeforeEach / It` convention. Acceptable for an integration test given complexity, but worth noting. --- ### Verdict REVIEW VERDICT: 0 blocker, 0 major, 3 minor
zombor force-pushed bd-bookshelf-r6lx.2 from ee8fa474fa
All checks were successful
/ JS Unit Tests (pull_request) Successful in 32s
/ E2E API (pull_request) Successful in 1m7s
/ Lint (pull_request) Successful in 1m15s
/ E2E Browser (pull_request) Successful in 2m12s
/ Integration (pull_request) Successful in 2m16s
/ Test (pull_request) Successful in 2m23s
to feeceaaee1
All checks were successful
/ JS Unit Tests (pull_request) Successful in 30s
/ Lint (pull_request) Successful in 51s
/ E2E API (pull_request) Successful in 1m35s
/ Integration (pull_request) Successful in 2m12s
/ Test (pull_request) Successful in 2m13s
/ E2E Browser (pull_request) Successful in 2m22s
2026-06-18 01:59:57 +00:00
Compare
zombor merged commit 910b589974 into main 2026-06-18 02:04:06 +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!583
No description provided.