You are implementing ONE feature in the Bookshelf Android app at ~/bookshelf. READ FIRST, in this order: 1. ~/bookshelf/docs/SPEC.md — the authoritative contract, especially "Non-negotiables", "Book metadata lookup" (the three-way outcome rule), "Design language", "Screens". Do not contradict it and do not edit it. 2. ~/bookshelf/docs/METADATA-SOURCES.md — "Measured again 2026-09-09" and "The key landed". Open Library is slow and long-tailed (median ~4s, max 22s) and fails ~13% of the time with fast TLS resets; Google Books is keyed at 1,000 req/day. 3. `logs/K-manual.summary` and `git show --stat HEAD` — the PREVIOUS wave (just committed) added a manual "Add a book by hand" screen (`ui/add/`) with a route that takes a pre-filled title/ISBN and a `BookDraft`. You will build on it. Read its code, not just the report. 4. `data/metadata/` in full (it is small), `ui/library/`, `ui/nav/`. ## The feature Today the library search bar filters only the user's own books, as they type. Extend it to also search Open Library and Google Books, so a user can find and add a book by title and/or author — including "book name + author name" when the title is common. The user's design, and the decisions they have already made — do not relitigate: 1. User types a query into the existing library search bar. 2. Own library filters AS THEY TYPE (instant, offline, as today). Online search runs ONLY ON SUBMIT (IME Search action, or an explicit button) — never per keystroke. Decided by the user: per-keystroke would burn the 1,000/day Google Books quota and results would thrash at OL's latency. 3. Results show an "In your library" section on top and online results below. 4. Results are DEDUPLICATED across the three sources (local, OL, GB): a book appears once. "In your library" means the user owns SOME edition of that book — decided by the user; an online hit that is a different edition of an owned book belongs in the library section, not the online one. 5. Tapping an online result opens a save sheet (same idea as the scan screen's Found sheet: book summary + shelf picker + Save / Skip). ISBN is filled in ONLY when it is unambiguous — see "ISBN rule" below. 6. At the bottom of the online results: "Can't find it? Enter it by hand" -> the previous wave's add screen, pre-filled with the query as the title. ## Verified API facts (orchestrator tested these live on 2026-09-13 — trust them) Both APIs treat a free-text `q` as matching across title AND author, so "hittite warrior williamson" and "the hobbit tolkien" both return the right book first. **Open Library** — `https://openlibrary.org/search.json?q=&limit=20&fields=` - Results are WORKS (`key` = "/works/OL2028038W"), not editions. - **`isbn` is NOT in the default response. You MUST pass `fields=`** or you will get no ISBNs at all. Use: `fields=key,title,subtitle,author_name,first_publish_year,edition_count,cover_i,isbn,publisher,number_of_pages_median` - `isbn` is a flat list mixing ISBN-10 and ISBN-13 of ALL editions, e.g. ["1883937388","9781883937386"] (one edition) or dozens for a popular work. It may be absent (pre-ISBN books). `cover_i` may be null. - Cover by id: `https://covers.openlibrary.org/b/id/{cover_i}-M.jpg`. That is a cover the source REPORTED, so it is legitimate under SPEC's cover rule. Never synthesize a by-ISBN cover URL. - Observed latency 3-6.5s. Real example of a near-duplicate: the same book returned as two works, authors "Joanne S. Williamson" and "Joanne Small Williamson". **Google Books** — `https://www.googleapis.com/books/v1/volumes?q=&maxResults=20&printType=books&key=` - Results are EDITIONS (volumes). `volumeInfo.industryIdentifiers` may be null. `imageLinks` may be absent. `publishedDate` can be garbage ("101-01-01" observed). - Popular titles return several editions of the same work ("The Hobbit" x5, author strings "J.R.R. Tolkien", "J. R. R. Tolkien", "John Ronald Reuel Tolkien"). - Observed latency ~0.7s. Same key and same redaction concerns as `GoogleBooksClient`. - Returns loosely-related noise after the real hits. Do not try to filter it; keep source order. ## What to build ### 1. Search clients (`data/metadata/`) A `search(query): SearchSourceResult` per source — add to the existing clients or add sibling classes, your call, but REUSE their machinery rather than copying it: - the same `OkHttpClient`, `withRetry` / `RetryPolicy`, `FailureKind`, `SourceResult.fromException` / `fromHttpCode`, and for GB the key handling, `requestUrl`-style pure URL construction, and `redact`. - three-way per source, never a bare null: Found(list) / NotFound (authoritative empty) / Failed(kind, reason). SPEC: a source that could not be reached must never be presented as "no results". - the wave-8 exception safety, exactly: never throws, `Throwable` -> `UNEXPECTED` with the exception class name only, `CancellationException` RETHROWN FIRST. - URL-encode the query via `HttpUrl.Builder` (not string concatenation). - GB cover URLs through the existing https+zoom normalization in `GoogleBooksDtos.kt` (it is `private` today; make it `internal` rather than duplicating it). - Wire into `AppContainer` (one or two lines; you may edit that file for this). ### 2. Local matching The current `BookDao.search` matches the whole string as one LIKE, so "hobbit tolkien" matches nothing. Replace it for this screen with token matching: normalize the query and the book (lowercase, strip diacritics, treat punctuation as space so "J.R.R." ~ "j r r"), split the query on whitespace, and require EVERY token to appear in at least one of title, subtitle, authors, isbn13, isbn10. A home library is hundreds to low thousands of books, so a pure Kotlin matcher over the already-observed book list is fine and far more testable than SQL — prefer that. If you do change `BookDao.search` instead, its Robolectric DAO tests must be updated, not deleted. ### 3. Merge + dedupe (pure, no Android, heavily tested) A pure object (e.g. `SearchResultMerger`) taking the local books and each source's result list, returning something like `SearchResults(inLibrary: List, online: List)`. Identity — two records are the same book if EITHER: a) they share any ISBN after normalizing everything to ISBN-13 (`IsbnUtils.toIsbn13`) — this is what joins a GB edition to its OL work, or b) normalized title AND first author's surname match. Normalized title: lowercase, strip diacritics and punctuation, drop a leading "the/a/an", drop anything after the first ':' (subtitles). Surname: last alphabetic token of the first author. ("Joanne S. Williamson" ~ "Joanne Small Williamson"; "J.R.R. Tolkien" ~ "John Ronald Reuel Tolkien".) Identity is transitive: group with union-find (or equivalent), not pairwise. Placement: - Any group containing a local book -> "In your library", shown as the local book(s). This includes local books reached ONLY via an online hit (the online record matched by ISBN/title but the local tokens did not). - Every other group -> ONE `OnlineBook` in the online section. Order by the group's best rank in either source (min position), so relevance is preserved and neither source is buried under the other. - Field fill for a merged OnlineBook: title/authors from OL if present (work-level, cleaner), else GB; year from OL `first_publish_year` else the year GB's `publishedDate` starts with IF it is a plausible 4-digit year; cover from OL `cover_i` else GB thumbnail; remember which sources contributed. **ISBN rule** (decided by the user — implement exactly): - If the group has an OL work whose ISBNs normalize to EXACTLY ONE distinct ISBN-13 -> use it. (A single-edition work, or one whose only ISBN-bearing edition is that one — ISBN-10 and ISBN-13 of the same edition are ONE ISBN.) - Else if the group's GB volumes carry exactly one distinct ISBN-13 -> use it (GB returned that specific edition for this query). - Otherwise -> no ISBN. Never guess among several. ### 4. Library screen UI (`ui/library/`) - IME action Search on the existing field triggers the online search. Because an IME action is invisible, ALSO show, whenever the query is non-blank and no online search has run for it, a clearly tappable row below the local results: "Search Open Library & Google Books for """. Minimum query 2 characters. - Sections: "In your library" (existing card style) then "Online" results as rows (small cover, title, authors, year). If there are no local matches, say so in one quiet line rather than hiding the section header — the user must be able to tell "not owned" from "didn't look". - Online states: loading (with the local section fully usable meanwhile — SPEC: no screen may block on network); results; authoritative no-results; per-source failure as a quiet line naming the source and its `reason` (e.g. "Google Books couldn't be reached — tls connection reset"), with Retry, while still showing the OTHER source's results; both failed -> a retry affordance that does NOT say "no results". - Editing the query after a submit clears the online section (and cancels any in-flight search): stale results for a different query are misleading. - Shelf/bookcase filter keeps applying to the local section only. - Tap an online result -> save sheet (bottom sheet on the library screen): cover, title, authors, year, ISBN if the rule gave one, shelf picker (reuse `ui/components/ShelfPickerSheet.kt` / `ShelfPicker` the way the scan sheet does, remembered last shelf included), buttons Save / "Edit details" / Skip. * Save -> `BookRepository.createBook` with everything known, `coverSourceUrl` = the cover URL (the repository already downloads it best-effort). Guard it: catch Throwable, rethrow CancellationException, show an error, keep the sheet. No double-tap double-save. After a save the book now appears in "In your library" by itself (Room flow) — verify that in a test rather than hand-moving it. * If an ISBN is known, ENRICH in the background: call the existing `MetadataRepository.lookup(isbn)` when the sheet opens and fill blanks (publisher, pages, description, better cover) via the same fill-blanks idea as `MetadataMerger` when it lands. Save must NEVER wait for this — if the user saves first, save what is known. Cancel it when the sheet closes. * "Edit details" -> the previous wave's add screen, pre-filled with the draft (title, subtitle, authors, publisher, year, pages, ISBN, description, cover URL if the draft supports it). If the add route cannot carry the full draft, extend it in `ui/add/` + `ui/nav/` minimally and consistently with how that wave built it (e.g. a serialized, URL-encoded draft argument, or a small draft holder). Say which you chose. - Keep the online search state in the ViewModel so it survives rotation. - Split stateless content composables so Paparazzi renders the REAL UI (see HANDOFF.md wave-6 "soft spots": a hand-rolled lookalike snapshot is not coverage). ## Constraints — these are hard - Kotlin. Match the surrounding code's style, naming and comment density — this codebase comments the evidence for a decision, not a restatement of the code. - You MAY touch: `data/metadata/`, `ui/library/`, `ui/add/`, `ui/nav/`, `ui/components/` (sharing only), `AppContainer.kt` (wiring only), and if you choose the SQL route, `data/local/BookDao.kt` + `data/repo/BookRepository.kt` search methods only. Plus tests. Nothing else. - DO NOT touch any build file. Every library you need is already declared (OkHttp, kotlinx-serialization, coil3, Room, Compose). If you think you need something else, STOP and say so. - DO NOT touch `data/remote`, `data/prefs`, sync code, `server/`, `docs/`. - DO NOT read, print or grep `app/local.properties`; never put a real API key in code, tests or your report. Tests use "test-key-123". Do NOT make live network calls from unit tests (an opt-in live test gated on an env var like the existing `LiveMetadataLookupTest` is welcome but not required). - DO NOT git commit, add or push. - Build with `./tasks/gw ` — NEVER `./gradlew`. - RUN BUILDS IN THE FOREGROUND. Never background a build and end your turn promising a later report — you will never get to give it. Previous workers did exactly this. - No emulator. You cannot run the app. Do not claim you did. ## Definition of done Run in the foreground and paste real output tails: ./tasks/gw assembleDebug -> exit 0 ./tasks/gw testDebugUnitTest -> exit 0. Count MUST GO UP from whatever the previous wave left (sum TEST-*.xml under app/app/build/test-results/testDebugUnitTest/ BEFORE you change anything, and again at the end). ./tasks/gw recordPaparazziDebug -> exit 0 ./tasks/gw verifyPaparazziDebug -> exit 0 Required tests with REAL assertions: - OL parse from a fixture shaped like the real responses above (mixed ISBN-10/13, missing isbn, null cover_i); GB parse with null industryIdentifiers and no imageLinks; the OL request URL contains `fields=` including `isbn`; the query is URL-encoded ("a&b c" -> not a broken URL); GB URL carries the key only when non-blank; GB failure reasons are redacted. - Each search client: HTTP error -> Failed not NotFound; empty docs -> NotFound; throwing interceptor -> Failed(UNEXPECTED), class name only; CancellationException propagates. - Local matcher: "hobbit tolkien" matches The Hobbit by J.R.R. Tolkien; "jrr tolkien" and "tolkien hobbit" too; diacritics ("bronte" ~ "Brontë"); a query token matching nothing excludes the book. - Merger: GB edition joins OL work by shared ISBN; "Joanne S. Williamson" and "Joanne Small Williamson" works collapse; "The Hobbit" x5 GB editions + OL work -> ONE online entry; an online hit of a DIFFERENT edition of an owned book lands in inLibrary, not online; transitivity (A~B by ISBN, B~C by title -> one group); ranking interleaves sources by best rank; distinct books with the same title but different authors stay separate. - ISBN rule, each branch: OL ["1883937388","9781883937386"] -> that ISBN-13; OL with many ISBNs + one GB ISBN -> GB's; several GB ISBNs and no single OL ISBN -> null; OL with no isbn field -> falls to GB or null. - ViewModel: typing does NOT trigger an online search; submit does; editing the query clears online results and cancels the in-flight search; one source failing still shows the other's results with a failure line; both failing is not reported as "no results"; save maps fields to createBook; a throwing save keeps the sheet with an error and does not throw. - Paparazzi light + dark: search with both sections populated; online loading while local results show; one source failed; the online-result save sheet. Grep the build output for Kotlin warnings on files you touched; "Check for instance is always 'false'" in particular is a real bug signal on this project, not noise. ## Report End with a plain report: - what you changed, file by file - verbatim tails of each gradle command - test count before and after, summed from the XML - how the draft reaches the add screen, and any change you made to the previous wave's route - your own judgement: where will dedupe get it WRONG on a real shelf (false merges and missed merges)? Name concrete cases. That is worth more than a clean report. - anything you could NOT do or did differently, and why - anything that looks wrong but was out of scope Be honest. Workers here have over-claimed before, and everything is re-verified.