You are implementing ONE change 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". Do not contradict it and do not edit it. 2. ~/bookshelf/docs/METADATA-SOURCES.md — section "The key landed" and the "Measured again 2026-09-09" section above it. This is the evidence base. 3. The files you will change (listed in "What to build"). ## The bug, and what is already known — do NOT redo this investigation On 2026-09-11 the Google Books API key landed (commit 486f6eb). On the user's phone, the FIRST barcode scanned after installing that build **crashed the app**. The ISBN was decoded and displayed, the spinner ran for a few seconds, then the process died. It has never reproduced, on that book or any other. The orchestrator has already investigated. Do not repeat any of this: - **The structural suspicion is sound.** Keyless Google Books returned HTTP 429 to every caller on the internet (a shared, exhausted daily quota — METADATA-SOURCES.md), so before that commit `GoogleBooksClient.classify`'s 2xx branch, `toBookMetadata()`, `normalizeCoverUrl()` and the two-source merge in `MetadataMerger` had NEVER ONCE EXECUTED in production. A first crash belongs in a path's first real exercise. - **It does not reproduce off-device.** There is now an opt-in live test, `app/app/src/test/java/org/modg/bookshelf/livemetadata/LiveMetadataLookupTest.kt`, which drives the real `MetadataRepository.lookup` against both real APIs with the real key. Nine lookups — both of the user's previously-failing ISBNs plus a control, three times each — all returned Found with cover art and nothing threw. Run it yourself if you like: `LIVE_METADATA=1 GOOGLE_BOOKS_API_KEY= ./tasks/gw testDebugUnitTest --tests '*LiveMetadataLookupTest*'`. (Get the key from the environment if it is set; do NOT read app/local.properties.) - Static review found no unsafe operation in that path: no `!!` outside a guarded branch, no date parsing, no unsafe indexing, and `ui/components/BookCover.kt` handles every `AsyncImagePainter.State`. **So do not go hunting for the throwing line. Nobody knows what it is, and you will not find it either.** Your job is to make the crash impossible whatever it was, and to make sure the next one leaves evidence. That is the whole wave. ## The actual defect you ARE fixing The lookup path has no exception safety anywhere along it: - `GoogleBooksClient.fetch` catches **only `IOException`**. `OpenLibraryClient.fetch` does the same. `classify` catches only `SerializationException` / `IllegalArgumentException`. - `ScanViewModel.runLookup` is called from inside `viewModelScope.launch { scannerController.scanResults.collect { ... } }` with **no try/catch at any level**. Both clients' KDoc claims "Never throws: every outcome, including transport failure, comes back as a SourceResult rather than a swallowed null." **That claim is currently false** — it is true only for `IOException`. Anything else that escapes on a real device (the platform TLS stack, an OkHttp internal, memory pressure, an Android API level difference — none of which the JVM here reproduces) takes the whole process down instead of surfacing as "couldn't be reached". That is a real, provable defect regardless of whether it is what bit the user, and it is exactly the failure class this project already decided to eliminate in wave 5: a failure the user cannot distinguish from anything else. ## What to build ### 1. The clients genuinely never throw In BOTH `data/metadata/GoogleBooksClient.kt` and `data/metadata/OpenLibraryClient.kt`, widen the catch in `fetch` from `IOException` to any `Throwable`, mapping the unexpected case to a `SourceResult.Failed`. - Add a new `FailureKind` for it — `UNEXPECTED` — rather than reusing `TRANSPORT`. It is NOT retryable (`RetryPolicy.isRetryable` must return false for it): we have no idea what went wrong, so repeating it is as likely to repeat the damage as to help. - The `reason` must name the exception class, e.g. `"unexpected: IllegalStateException"`. That string is rendered on the scan sheet and is our ONLY diagnostic channel from a real phone. Include the class name; do NOT include the message (it can contain a URL, and therefore the API key). `GoogleBooksClient.redact` still applies on top. - **`CancellationException` MUST be rethrown, not converted.** This is the one way to get this wrong. Coroutine cancellation is normal control flow — the user dismissing the sheet or leaving the screen cancels the lookup — and swallowing it turns a cancelled coroutine into a "failed lookup" and breaks structured concurrency. `kotlin.coroutines.cancellation.CancellationException`. Rethrow it first, before any other handling. - Keep the existing `IOException` arm and its specific classification (`SourceResult.fromException`) exactly as it is. `UNEXPECTED` is the fallthrough for what that function never sees, not a replacement for it. ### 2. The ViewModel survives anything the layer below does In `ui/scan/ScanViewModel.kt`, `runLookup` must not be able to kill the process. Wrap its body so that any `Throwable` becomes the existing "lookup failed" sheet state (`ScanSheetState.LookupFailed` — read `ScanModels.kt`; it already carries a reason string and already offers Retry and "Enter by hand", which is exactly the right affordance here). - Again: rethrow `CancellationException` first. - Note that `bookRepository.findByIsbn13(isbn13)` is inside this function too, so this guard covers a Room/disk failure as well, not just the network. - The reason string should mark it as unexpected and name the exception class, so it is distinguishable on the sheet from an ordinary source failure. - This is deliberately belt-and-braces with §1. Keep both: §1 keeps one source's problem from destroying the other source's answer, §2 catches anything that escapes anywhere else in the function. ### 3. A crash leaves evidence New package `org.modg.bookshelf.diagnostics`. A `CrashReporter` that: - Installs a `Thread.setDefaultUncaughtExceptionHandler` from `BookshelfApplication.onCreate`. - **Chains to the previously-installed handler at the end, always.** If you do not, the process hangs instead of dying and Android never shows its dialog or records the crash. Capture the previous handler before installing, and call it in a `finally`. - Writes ONE file per crash into the app's `filesDir`, containing: an ISO-8601 timestamp, the app `versionName`/`versionCode`, `Build.MODEL`, `Build.VERSION.SDK_INT`, the crashing thread's name, and the full stack trace INCLUDING the cause chain (`Throwable.printStackTrace` on a `PrintWriter` over a `StringWriter` gives you the causes for free — do not hand-roll the walk). - Keeps only the most recent 5 and deletes older ones, so this cannot grow without bound on a phone. - Cannot itself throw: the whole body goes in `runCatching`, because an exception inside an uncaught-exception handler is the worst possible place to have one. Must not do I/O on the main thread at startup — writing happens only when a crash happens, and reading happens only when the user opens the screen in §4. ### 4. The user can actually read it Add a "Diagnostics" section to the settings screen (`ui/settings/`). It shows the most recent crash — timestamp and the exception's first line — with a way to see the full trace and get it off the phone. Getting it off the phone is the entire point: the user is in another country from this machine and screenshots of a stack trace are painful. Offer a share action (`Intent.ACTION_SEND`, `text/plain`) and a copy-to-clipboard. When there are no crash files, say so plainly — an empty "Diagnostics" section that looks broken is worse than none. Match the existing settings screen's structure and visual language exactly; read it before writing. Follow SPEC's "Design language" section. This screen has Paparazzi coverage — add snapshots for the new section in both light and dark, in the same style as the existing settings snapshots, and record them. ### 5. Update the comments you invalidate Both clients' class KDoc claims they never throw. After §1 that becomes true — say so accurately, and say WHY the `UNEXPECTED` kind exists (the crash above, undiagnosed, 2026-09-12). This codebase comments with the evidence for a decision, not a restatement of the code. Match that. ## Constraints — these are hard - Kotlin. Match the surrounding code's style, naming and comment density. Read neighbouring files before writing and follow their conventions. - DO NOT touch: app/app/build.gradle.kts, gradle/libs.versions.toml, or any other build file. If you believe you need a new dependency, STOP and say so in your report instead — everything above is doable with what is already declared. - DO NOT touch anything under data/local, data/remote, data/repo, data/prefs, or server/. You may edit AppContainer.kt and BookshelfApplication.kt only as far as §3 requires. - DO NOT read, print, cat, grep or echo `app/local.properties`, and never paste a real API key into a source file, a test, or your report. Tests use a fake like "test-key-123". - 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 when it finishes. Three previous workers on this project did exactly that and could never report. A full build takes 1-3 minutes; wait for it. - There is no emulator on this box (no KVM). You cannot run the app. Do not claim you did. ## Definition of done Run these yourself and paste the real output: ./tasks/gw assembleDebug -> exit 0 ./tasks/gw testDebugUnitTest -> exit 0. **190 tests pass today, 2 skipped** (LiveSyncTest and LiveMetadataLookupTest, both opt-in live tests). The count must go UP and nothing may regress. Get the count by summing the TEST-*.xml files, not by reading the console. ./tasks/gw recordPaparazziDebug -> exit 0, for the new settings snapshots (§4) ./tasks/gw verifyPaparazziDebug -> exit 0 against the re-recorded snapshots Required new tests, with REAL assertions (assertion-free tests are a spec violation on this project): - each client returns `Failed(kind = UNEXPECTED)` — not a crash — when the HTTP call throws a non-IOException `RuntimeException`. An `Interceptor` that throws is the straightforward way to provoke this with the real client. - the reason names the exception class and does NOT contain the exception's message. - `CancellationException` propagates out of a client rather than becoming a `Failed`. Test this explicitly. It is the easiest thing here to get wrong. - `RetryPolicy.isRetryable(UNEXPECTED)` is false, and `withRetry` therefore makes exactly one attempt. - `ScanViewModel.runLookup` puts the sheet into LookupFailed (and does not throw) when the repository throws; and a `CancellationException` is not swallowed. - `CrashReporter` writes a file containing the exception class and its cause's message; retains only the newest 5; and DELEGATES to the previously-installed handler (assert the previous handler was actually invoked). Also: grep the build log for Kotlin warnings on the 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. ## Report End with a plain report covering: - what you changed, file by file - the verbatim tail of each gradle command above - the test count before and after, summed from the XML - **your own judgement call on this**: does §1+§2 actually make the lookup path non-crashing, or is there still a way for a throwable to reach the top? Say so plainly if there is. Naming a remaining hole is worth more than a clean report. - 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. Workers on this project have over-claimed success before, and the orchestrator independently re-verifies everything, so an inflated report only wastes a round trip.