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. Sections "Book metadata lookup" and "Barcode scanning" were just rewritten for this task. They are the spec you are implementing. Do not contradict them and do not edit SPEC.md. 2. ~/bookshelf/docs/METADATA-SOURCES.md — why this work exists, section "What actually failed". ## The problem The app cannot tell three different things apart, and reports all of them identically or not at all: (1) A barcode was decoded but is not a valid ISBN-13. ScanCodeFilter.accept() returns null. NOTHING happens. No sheet, no message. To the user this is indistinguishable from a camera that isn't working. (2) A lookup request FAILED — non-2xx (Google Books returns HTTP 429 to keyless callers), timeout, or transport error. Both OpenLibraryClient.fetchBody and GoogleBooksClient.fetchBody collapse this to null, MetadataRepository collapses that to null, and the UI writes "No match found — enter the details by hand." The app tells the user a book does not exist when in truth it never managed to ask. (3) The lookup genuinely succeeded and neither source has the book. This is the ONLY case where "No match found" is honest. This is not hypothetical. Two real books off the user's shelf (9781883937386 "The Hittite Warrior", 9781883937676 "Shadow Hawk") failed on the phone, and both are fully present in Open Library — there are regression tests proving the app's own parser handles their real API responses. The bug is in how failure is classified and shown, NOT in coverage. Do not "fix" this by adding a data source. ## What to build ### 1. Per-source classification (data.metadata) Give each client a three-way outcome instead of `BookMetadata?`: sealed interface SourceResult { data class Found(val metadata: BookMetadata) : SourceResult data object NotFound : SourceResult // answered 2xx, no record data class Failed(val reason: String) : SourceResult // non-2xx/timeout/IO/parse } `reason` is a short diagnostic string ("http 429", "timeout", "malformed json") — it is for the log and for us, NOT prose to show the user verbatim. CRITICAL for testability: there is no MockWebServer in this project and you must not add one. Put the classification in a PURE function that takes what an HTTP response gives you and returns a SourceResult, e.g. internal fun classify(httpCode: Int, body: String?, isbn13: String): SourceResult and have fetch/lookup call it. Then it can be unit-tested exhaustively offline, the way parseResponse already is. Keep the existing internal parseResponse functions and their tests working. ### 2. Combination (MetadataRepository) sealed interface LookupResult { data class Found(val metadata: BookMetadata) : LookupResult data object NotFound : LookupResult data class Unavailable(val reason: String) : LookupResult } Rules, exactly as SPEC states them: - any source Found -> Found (merge as today via MetadataMerger) - all sources NotFound -> NotFound - otherwise (>=1 Failed, none Found) -> Unavailable That last rule is the whole point: a single reachable source saying "no" is NOT authoritative while the other source could not be reached. Unit-test the full matrix — 3x3 of (OL outcome, GB outcome) — with real assertions. Keep MetadataRepository.byIsbnCoverUrl and the existing last-resort cover behaviour intact. An invalid ISBN (IsbnUtils.toIsbn13 returns null) is a programming error at this layer, not a lookup outcome — keep it out of LookupResult; the scan layer must never send one. ### 3. Give the metadata HTTP client a call timeout (AppContainer) `metadataHttpClient` is a bare `OkHttpClient()` with no call timeout, so a stalled connection hangs on default socket timeouts. Give it an explicit callTimeout (10-15s is right — the user is standing at a bookshelf) plus connect/read timeouts. Leave the PocketBase okHttpClient alone; it is a different client for a reason (it carries the auth token) and sync is not in scope. ### 4. Scanner feedback for a rejected barcode (ui.scan) ScanCodeFilter.accept() currently returns String?. Make the rejection visible: sealed interface ScanOutcome { data class Isbn(val isbn13: String) : ScanOutcome data class NotAnIsbn(val rawValue: String) : ScanOutcome data object Ignored : ScanOutcome // debounced repeat: emit NO ui at all } Route NotAnIsbn through ScannerController to the ScanScreen, which shows a transient message near the reticle, e.g. "Read 012345678905 — not a book barcode". THROTTLING IS MANDATORY AND IS THE EASY THING TO GET WRONG. A non-book barcode sitting in frame decodes on almost every analyzed frame. The message must not flicker or re-trigger per frame: debounce the same rejected code the way repeats of a valid code are already debounced, and let the message auto-clear after a few seconds. Unit-test the throttle with an injected clock — ScanCodeFilter already takes `nowMillis: () -> Long` for exactly this; use it, do not use real time in tests. ### 5. UI states (ui.scan) ScanSheetState gains a failure case alongside the existing ones: data class LookupFailed(val isbn13: String, val reason: String) : ScanSheetState - Found -> existing FoundBookSheet, unchanged - NotFound -> existing ManualEntrySheet. Reword its copy so it reads as an authoritative negative ("Not in Open Library or Google Books"), not as a generic failure. - LookupFailed -> a NEW sheet that says the lookup could not be completed and offers: Retry (re-runs the lookup for that same ISBN), Enter by hand (falls through to the manual-entry form, ISBN pre-filled), and Skip. It must NOT say or imply the book is unknown. Wire Retry properly: it re-enters the loading state and re-runs the lookup. Do not leave a sheet that can strand the user — note that ScanViewModel.onScanned early-returns while any sheet is showing, so a sheet that cannot be dismissed blocks every subsequent scan. ## Constraints — these are hard - Kotlin, Jetpack Compose, Material 3. Match the surrounding code's style, naming and comment density. Read neighbouring files before writing. - Design language is in SPEC.md "Design language". Reuse existing components (PrimaryButton, SecondaryButton, EmptyState, BookCover...). Do not invent new colours or typography. - DO NOT touch: app/build.gradle.kts, gradle/libs.versions.toml, any file under data/local, data/remote, data/repo, or ui/settings, ui/locations, ui/detail. Every dependency you need is already declared. If you believe you need a new one, STOP and say so in your report instead. - DO NOT edit docs/SPEC.md, docs/HANDOFF.md or docs/METADATA-SOURCES.md. - DO NOT git commit, git add, or git push. The orchestrator commits. Leave your work in the working tree. - Build with `./tasks/gw ` — NEVER `./gradlew` directly (tasks/gw is a flock-serialized wrapper). - RUN BUILDS IN THE FOREGROUND. Do not background a Gradle build and end your turn saying you will report later — a previous worker did exactly that and could never report. A full build here takes 1-3 minutes; just wait for it. - There is no emulator on this box (no KVM). You cannot run the app. Verify with assembleDebug, unit tests, and Paparazzi. ## Definition of done All of these must pass, and you must run them yourself and paste the real output: ./tasks/gw assembleDebug -> exit 0 ./tasks/gw testDebugUnitTest -> exit 0, and the pre-existing 107 tests still pass (only LiveSyncTest may be skipped) ./tasks/gw recordPaparazziDebug -> exit 0 Also required: - New unit tests with REAL assertions for: the classify() function per source (2xx-with-record, 2xx-without-record, 404, 429, 500, malformed body), the 3x3 LookupResult combination matrix, and the ScanOutcome throttle with an injected clock. Assertion-free tests are a spec violation. - A Paparazzi snapshot for the new LookupFailed sheet, and one for the camera overlay showing a rejected-barcode message, in BOTH light and dark. Follow the existing pattern in src/test/java/org/modg/bookshelf/ui/screens/ScanScreenPaparazziTest.kt. - Check the build log for Kotlin warnings on files you touched. A warning reading "Check for instance is always 'false'" is NOT cosmetic — that exact warning hid a bug that blanked every book cover in this app for months. If you see it, you have written dead code; fix it. ## Report End with a plain report covering: - what you changed, file by file - the verbatim tail of each of the three gradle commands - the test count before and after - anything you could NOT do, or did differently from these instructions, and why - anything you noticed that looks wrong but was out of scope Be honest. A worker on this project has over-claimed success before, and the orchestrator independently re-verifies everything, so an inflated report only wastes a round trip.