fix(metadata): honour field locks on every Hardcover write path (#2767) - #2773
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
models.Book documents the contract that every refresh, enrichment and merge path must check IsFieldLocked before overwriting a lockable field. No path under internal/bookhydrate or internal/metadata did, until #2760 added the first check for Language. This generalises that fix to the rest. The live one is Description. HydrateHardcoverEditions hands the stored book to Aggregator.EnrichAudiobook and then persists it through Books.Update, so a description the user cleared and locked was refilled from the Audnex summary on the next hydrate and written to the database. That is #2757 one field over, reached by the same call path. The enrichment merge itself is the other half: enrichBook replaces Description whenever a provider string is longer and replaces Genres outright for Hardcover, with no emptiness gate to hide behind. applyEnrichmentSnapshot replays both decisions from the cache, so a guard on only the live path would reopen the bug on the next cache hit. The snapshot is the post merge book rather than the provider payload, and its key is (provider, foreignID), which every book for that work shares. Now that a locked field keeps the user's value, caching from a locked book would serve that value to another library enriching the same work, so a locked book no longer seeds the cache at all. Book.CanWrite is the one way this question is asked in these two packages, so there is a single name to grep for. It answers ownership only. Emptiness stays a separate test at each site, because fill when empty and overwrite unconditionally are different merge rules and a lock has to stop both. Conflating them is what produced #2757. ImageURL stays out of LockableBookFields and stays unguarded. There is no cover field in the edit dialog, so nothing can lock it today, and adding it would be a user visible capability change needing the edit UI and docs/Metadata-Editing-Wiki.md to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
50a6b08 to
bf708ac
Compare
There was a problem hiding this comment.
Clean fix. The headline bug is real and the test at hardcover_locked_description_test.go:31 wires to the actual call path (aggregator → hydrate → Books.Update) rather than a fake, which is the right choice.
One non-blocking observation on the cache gate at aggregator_enrichment.go:351:
if cacheKey != "" && book.CanWrite(models.BookFieldDescription) && book.CanWrite(models.BookFieldGenres) {
Locking either field suppresses the whole snapshot, so a book with only genres locked also loses the imageURL/averageRating/ratingsCount cache entries. That means the next library enriching the same work pays one extra provider round-trip for those fields even though they are unlocked and their post-merge values in the book are purely provider-owned. The PR body already explains the tradeoff ("barely happens"); confirming the implication is understood and the reasoning holds — it does, since caching partial snapshots or zeroing locked fields before storing would be substantially more complex for a rare case.
Everything else looks right:
- CanWrite nil-safety (models/book.go:165) is correct — returning true is the safe choice.
- The decision to leave ImageURL unguarded is sound; the comment at bookhydrate/hardcover.go:232 documents it for the next reader.
- Mutation table in the PR body accounts for all seven guards.
- Scope is narrow: internal/abs, internal/calibre and internal/api are deliberately untouched.
— 🤖 Bindery triage bot (automated). Reply to correct me; a human will see it.
Summary
Closes #2767.
models.Bookstates the contract: every refresh, enrichment and merge path must checkIsFieldLockedbefore overwriting a lockable field (internal/models/book.go:67-69). Nothing underinternal/bookhydrateorinternal/metadatadid, until #2760 added the first check forLanguage. This generalises that fix to the rest of the two packages.What was actually broken
I re-verified each claim in the issue against
origin/mainat8e023eb1. Two of the four hold as filed, one is off by a few lines, and two are not reachable the way the issue describes.aggregator_enrichment.go:36-38,EnrichAudiobookfillsDescriptionwhen emptyaggregator_enrichment.go:288,enrichBookoverwrites a longerDescriptionaggregator_enrichment.go:317,enrichBookreplacesGenresfor Hardcoverbookhydrate/hardcover.go:237writesImageURLEnrichAudiobookis the one that reaches a stored book.HydrateHardcoverEditionscalls it atinternal/bookhydrate/hardcover.go:143on the book it loaded, and theBooks.Updateathardcover.go:157persists whatever it wrote. Two other call sites do the same:internal/api/books.go:174andinternal/abs/importer.go:822. A user who clears a description and locks it got it refilled from the Audnex summary on the next hydrate. That is #2757 one field over, on the same call path.enrichBookandapplyEnrichmentSnapshotnever see a stored book today. All four call sites hand them a provider transport object built by a provider client, which carries noLockedFields:aggregator.go:814(GetBook),aggregator.go:1004(cacheISBNBook),aggregator_author_works.go:777(enrichMissingAuthorWorkCovers),aggregator_enrichment.go:79(the ASIN canonicalizer). I traced where those enriched objects land: the author refresh merge atinternal/api/authors.go:2410already checks the genre lock, the ABS path goes throughapplyBookFieldswhich checks all five, and rebind (books.go:1149) and metadata re-map (books.go:1277) clearLockedFieldson purpose because they mean "take the new record wholesale". So there is no live leak through these two functions. I guarded them anyway, because the contract is unconditional and a future caller passing a stored book is exactly the mistake the contract exists to prevent, but I am not claiming a user-visible fix there. The tests pin the behaviour, not a reproduction.One consequence worth flagging
enrichBookseeds a process wide cache from the post merge book, keyed on(provider, foreignID), which every book for that work shares across users. Once a locked field keeps the user's own value, caching it would serve that user's hand written description to the next library that enriches the same work. So a book with a description or genre lock no longer seeds the cache at all. It costs one extra enricher round trip in a case that barely happens; every unlocked book caches exactly as before. Pinned byTestEnrichBookDoesNotCacheFromALockedBook.Also checked, no change needed
mergeAuthorWorkMetadata(aggregator_author_works.go:831): the audit's judgement is correct.dstis an element of the[]models.Bookreturned byGetAuthorWorks, built by the provider clients, soLockedFieldsis always empty there. Its output reaches stored books throughinternal/api/authors.go, which does its own lock check on genres. Left alone: a guard there would be reading a field that is structurally always empty.internal/hardcoverlistsyncer(out of scope, confirmed rather than assumed):enrichAudiobookis called from exactly one place,syncer.go:609, immediately afters.books.Createon a book the syncer just built. No lock can exist on a row that did not exist a line earlier.Decisions
1.
ImageURLstays out ofLockableBookFieldsand stays unguarded. There is no cover field in the edit dialog, and a lock is only ever created by editing a field (internal/api/books.go:466-497), so nothing can lock a cover today. Adding it would be a user visible capability change: the edit UI needs a control,docs/Metadata-Editing-Wiki.mdlists the five editable fields and would need a sixth row, and thePUT /api/v1/book/{id}contract would widen. That is a feature, not this bug fix. BothImageURLwrites stay fill-empty and now carry a comment saying why, so the next reader does not have to re-derive it.2.
Book.CanWrite(field)is the single mechanism. One name to grep for instead of scattered!book.IsFieldLocked(...). It is used at every one of the seven sites in these two packages, including converting theLanguageguard #2760 added, so there is no second idiom left here. I deliberately did not churninternal/abs,internal/calibre,internal/importerorinternal/api, which keep their existingIsFieldLockedcalls: that is unrelated blast radius for a bug fix.3. Fill and overwrite are separate tests.
CanWriteanswers ownership only. The emptiness check stays where it was at each site, spelled out next to it. The three shapes now read:book.Description == "" && b.Summary != "" && book.CanWrite(...)len(e.Description) > len(book.Description) && book.CanWrite(...)enricher.Name() == "hardcover" && len(e.Genres) > 0 && book.CanWrite(...)A lock blocks all three. Collapsing them into one "is it empty" question is precisely what produced #2757, so the helper refuses to answer it.
Fail before evidence
Every new test was run against
origin/mainbehaviour first (source reverted,CanWritestubbed in so the tests compile).That is the headline: a description locked while empty, a Hardcover hydration whose Audnex enrichment supplies a summary, and the summary lands in the database. The other three sub-cases pass on main, which is the point: the emptiness gate hid everything except the deliberate clear.
The headline test is deliberately wired to a real
metadata.Aggregatorwith a stubbed Audnex client rather than a fake enricher. A fake that consulted the lock itself would have been testing the fake.Mutation check
Each guard broken in turn (
&& book.CanWrite(...)to&& true || book.CanWrite(...)), test run, guard restored. All seven are pinned by a named test:aggregator_enrichment.go:42EnrichAudiobook descriptionTestEnrichAudiobookHonoursDescriptionLock, andTestHydrateHardcoverEditionsRespectsDescriptionLock:301enrichBook descriptionTestEnrichBookHonoursDescriptionLock:329enrichBook genresTestEnrichBookHonoursGenreLock:351cache seedTestEnrichBookDoesNotCacheFromALockedBook:372snapshot descriptionTestApplyEnrichmentSnapshotHonoursLocks:387snapshot genresTestApplyEnrichmentSnapshotHonoursLocksbookhydrate/hardcover.go:232languageTestHydrateHardcoverEditionsRespectsLanguageLock(#2760's test, still pinned after the conversion toCanWrite)Nothing unpinned.
Checklist
git commit -sdocs/DEPLOYMENT.mdupdated if env vars, config, or upgrade path changed (no env, config or upgrade change)changelog.d/docs/Metadata-Editing-Wiki.mdalready promises locks survive every automatic refresh. This makes that true rather than changing it, so there is nothing to correct.)Test plan
go build ./... && go vet ./...go test ./cmd/... ./internal/..., fully greenGOOS=windows go build ./...andGOOS=darwin go build ./...🤖 Generated with Claude Code
https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9