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, section "Book metadata
     lookup". Do not contradict it and do not edit it.
  2. ~/bookshelf/docs/METADATA-SOURCES.md — why this work exists. Read section
     "Measured again 2026-09-09" in full; it is the evidence base for every
     decision below.
  3. The files you will change: data/metadata/GoogleBooksClient.kt,
     data/metadata/MetadataRepository.kt, data/metadata/RetryPolicy.kt,
     data/metadata/SourceResult.kt, AppContainer.kt, and their tests.

## Background — what is already done, do NOT redo it

The user has obtained a Google Books API key and put it in `app/local.properties`
as `GOOGLE_BOOKS_API_KEY=...`. That file is gitignored.

The ORCHESTRATOR has ALREADY done the build plumbing, and it is verified working:
`app/app/build.gradle.kts` reads that property (falling back to the environment
variable of the same name, then to empty string), enables the `buildConfig`
feature, and emits `BuildConfig.GOOGLE_BOOKS_API_KEY`. `assembleDebug` is green
and the generated BuildConfig carries the real key.

So: **the key is already available to Kotlin code as
`org.modg.bookshelf.BuildConfig.GOOGLE_BOOKS_API_KEY`.** You do not need to, and
MUST NOT, touch any build file. If you think you need to, stop and say so in your
report instead.

The key has been tested live from this machine against all three of these ISBNs —
9781883937386, 9781883937676, 9780140449136 — and returned HTTP 200 with the
right titles. The key works. Your job is to make the app use it.

## Why this matters (so you make the right call on ambiguity)

Keyless Google Books returns HTTP 429 to EVERY caller on the internet, forever:
all keyless traffic bills to one shared anonymous Google Cloud project whose daily
quota is permanently exhausted. That is a measured fact, not a guess — see
METADATA-SOURCES.md.

The knock-on is the part that actually hurt the user. `MetadataRepository.combine`
turns "at least one source Failed, none Found" into `Unavailable`. Google Books has
been a PERMANENT standing failure, so **every** Open Library hiccup surfaced to the
user as "one or more sources couldn't be reached". The app has effectively been
single-sourced this whole time while reporting as though two sources were
consulted. Making the key work restores the second source and, with it, the
honesty of `Unavailable`.

## What to build

### 1. GoogleBooksClient sends the key

Give `GoogleBooksClient` an `apiKey: String` constructor parameter, defaulting to
`""`. Append `&key=<apiKey>` to the request URL ONLY when the key is non-blank.

A blank key must keep today's exact behaviour (keyless request, which 429s). This
is not a hypothetical: a fresh clone of this repo has no `local.properties` key,
and its build must still produce a working app rather than a broken one.

URL-encode the key (`java.net.URLEncoder` or okhttp's `HttpUrl.Builder`). Prefer
`HttpUrl.Builder` — okhttp is already a dependency and it encodes query parameters
correctly by construction.

CRITICAL FOR TESTABILITY: there is no MockWebServer in this project and you must
not add one. Put URL construction in a pure, package-visible function that takes
the ISBN and returns the URL string, exactly the way `classify()` is already
factored out for the same reason:

    internal fun requestUrl(isbn13: String): String

`fetch()` then calls it. That makes the whole feature unit-testable offline.

### 2. The key must never leak into anything user-visible or logged

`SourceResult.Failed.reason` is RENDERED ON THE SCAN SHEET and is our only
diagnostic channel from a real phone. A `reason` that carried the request URL
would put the API key on the user's screen and into any screenshot they send us.

  - Never build a `reason` from the request URL.
  - `SourceResult.fromException` derives its reason from exception type/message.
    Some okhttp/JDK IOExceptions DO include the full URL in their message
    (`java.net.UnknownHostException`, SSL errors, and okhttp's own
    `java.io.IOException: Canceled` variants differ by platform). Add a redaction
    step so that ANY reason string passing through has an API key replaced with a
    placeholder before it can be stored or shown.
  - Write a unit test that asserts the key does not appear in the reason for a
    failure whose exception message contains the full keyed URL. Assert on the
    literal key string you pass in — a test that only checks for "AIza" is not
    good enough, because the test key you construct should not look real.

Do not log the URL anywhere either. (`metadataHttpClient` in AppContainer has no
logging interceptor today — do not add one.)

### 3. Wire the key through (AppContainer + MetadataRepository)

`MetadataRepository`'s convenience constructor `(httpClient, json)` builds both
clients itself. Add a `googleBooksApiKey: String = ""` parameter to it and pass it
to `GoogleBooksClient`. Keep the primary constructor (which takes the two clients)
as it is — the tests use it.

In `AppContainer`, pass `BuildConfig.GOOGLE_BOOKS_API_KEY` when constructing
`MetadataRepository`. That single line is the ONLY change you may make to
AppContainer.kt, and AppContainer.kt is the only file outside `data/metadata` you
may touch at all.

### 4. A keyed 429 is a different animal — make it retryable, once

`RetryPolicy.isRetryable` deliberately refuses to retry `RATE_LIMITED`, and its
KDoc explains why AND explicitly parks this decision for the wave you are now
doing. Read that KDoc before writing anything here.

The reasoning: today's keyless 429 is an exhausted DAILY quota that never clears
within a scanning session, so retrying is pure waste and risks turning an
intermittent block into a permanent one. A KEYED 429 is usually the per-user
per-100-seconds rate limit instead, which does clear, and is worth exactly one
more ask.

Implement it narrowly:
  - `withRetry` gains a parameter, default `false`, that permits ONE retry of a
    RATE_LIMITED failure — e.g. `retryRateLimitedOnce: Boolean = false`.
    Open Library's call site keeps the default and its behaviour must not change.
  - `GoogleBooksClient` passes `apiKey.isNotBlank()` for it. Keyless stays
    unretried, which preserves today's measured-correct behaviour.
  - One retry means one: a second 429 is returned to the caller, never a third
    attempt, regardless of `MAX_ATTEMPTS`.
  - Respect a `Retry-After` header if the response carries one, but CAP the wait
    at ~2 seconds. The user is standing at a bookshelf. If the header is absent,
    unparseable, or over the cap, use a short fixed backoff instead of honouring
    it. Note this means `classify()` — currently `(httpCode, body)` — needs the
    header value to reach the retry decision; thread it through in whatever way
    keeps `classify` a pure function, and keep its existing tests passing.
  - Unit-test all of it with the injected `sleep`/`nowMillis` hooks `withRetry`
    already has. No test may sleep on the real clock.

Keep every existing guarantee in `withRetry` intact, including the "N attempts"
suffix on a surviving failure's `reason`.

### 5. Update the stale comments you invalidate

Several comments now describe a world that no longer exists — the KDoc at the top
of `GoogleBooksClient` says "No API key", its `lookup` KDoc explains why retrying
costs nothing today, and `RetryPolicy`'s RATE_LIMITED bullet parks the decision you
are implementing. Update each to describe the new behaviour. Do not leave a comment
that contradicts the code; this project treats that as a defect.

Do NOT edit docs/SPEC.md, docs/HANDOFF.md or docs/METADATA-SOURCES.md — the
orchestrator owns those.

## Constraints — these are hard

- Kotlin. Match the surrounding code's style, naming and comment density. These
  files are heavily commented with the EVIDENCE for each decision, not with
  restatements of the code. Read neighbouring files before writing and follow that
  convention.
- DO NOT touch: app/app/build.gradle.kts, gradle/libs.versions.toml, or any other
  build file. The plumbing is done and verified.
- DO NOT touch anything under data/local, data/remote, data/repo, data/prefs, or
  any ui/ package. AppContainer.kt is limited to the one line in §3.
- DO NOT read, print, cat, grep or echo `app/local.properties` or any file that
  might contain the key, and never paste a real key into a source file, a test, or
  your report. Tests must use an obviously-fake key 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 <task>` — 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 — two previous workers 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.

## 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. 172 tests pass today (1 skipped,
                                       LiveSyncTest). The count must go UP and
                                       nothing may regress.
  ./tasks/gw verifyPaparazziDebug   -> exit 0. This change is not supposed to move
                                       a single pixel; if a snapshot fails, you
                                       changed something you should not have.

Required new tests, with REAL assertions:
  - requestUrl includes `key=` with a non-blank key, and omits it entirely when
    the key is blank.
  - a key needing URL-encoding is encoded.
  - the key is redacted out of a Failed reason built from an exception whose
    message contains the keyed URL (§2).
  - a keyed 429 is retried exactly once; a keyless 429 is not retried at all;
    a second 429 ends it.
  - Retry-After is honoured when short, capped when long, ignored when absent or
    unparseable.
Assertion-free tests are a spec violation on this project.

Also: 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. 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.
