# Changelog

## Unreleased

## v1.4.1 — 2026-04-21

### Fixed

- **Unhelpful `Invalid API key` error when a user pastes an OpenRouter provisioning/management key into `coarse setup` or the web form.** OpenRouter exposes two key types: regular API keys (used to call `/v1/chat/completions`) and provisioning keys (used programmatically to create/list API keys). Pasting a provisioning key into coarse surfaces as a `401 Unauthorized` from OpenRouter with body `{"error":{"message":"User not found.","code":401}}` — but `src/coarse/extraction_openrouter.py::_classify_api_error` mapped every 401 to the generic `"Invalid API key. Check that your key is correct and active."` copy, which sends users to check-is-my-key-typed-correctly rather than the real issue. Diagnosed from Modal logs on 2026-04-20: three consecutive submissions for one user failed with exactly this pattern while their OpenRouter key length (73 chars, `sk-or-v1-…` prefix) looked correct to them. Fix: `_classify_api_error` now inspects the response body summary via the existing `_describe_api_error` helper and — when it contains `User not found` on a 401 — returns a management-key-specific message that names the problem and links to `https://openrouter.ai/settings/keys` with the exact button to click. Generic 401s without that body fragment keep the existing copy.

### Changed

- **`web/src/app/setup/page.tsx` Step 3 now warns against provisioning keys.** One-paragraph note under the "Create Key" instruction explaining that provisioning/management keys from the integrations section can create and list other keys but can't run inference, and that coarse will fail with `User not found` if pasted. Paired with the classifier fix above so users who hit the error in Modal get redirected to the same guidance that now lives on the setup page.

### Removed

- **Google Analytics 4 dropped from coarse.ink (web-only hotfix).** The previous setup loaded `googletagmanager.com/gtag/js` unconditionally for every visitor in `web/src/app/layout.tsx` and set `_ga` / `_ga_*` cookies before any user interaction. Under EU/UK ePrivacy + GDPR this is a non-essential tracker that requires prior informed consent via a banner, and the site has no such banner. Rather than add a banner for an audience (academic / dev-tool users) where GA's data was never going to drive product decisions, GA is removed entirely: the two `<Script>` tags and the `GA_ID` constant deleted from `layout.tsx`, the `googletagmanager.com` origin dropped from the `script-src` CSP directive in `web/next.config.ts`, and `www.google-analytics.com` + `analytics.google.com` dropped from `connect-src` so the CSP stops whitelisting dead endpoints. `web/src/app/privacy/page.tsx` "What we collect", "Analytics", and "Third-party services" sections updated to reflect the new no-third-party-analytics posture and note that Turnstile is the only external JS (strictly necessary). No cookie banner needed as a result — the site now only sets strictly-necessary cookies (Cloudflare `__cf_bm`, Turnstile challenge, Vercel edge protection). Delivered as a hotfix directly on `main` per maintainer request; mirrored back to `dev` immediately after to prevent drift.

## v1.4.0 — 2026-04-19

### Fixed

- **`anthropic/claude-opus-4.7` now works end-to-end — temperature is conditionally omitted for models whose providers reject it (issue #162).** Opus 4.7 is a reasoning-first Claude release that dropped the `temperature` parameter. Both the direct Anthropic route and OpenRouter's Anthropic route return HTTP 400 on any request that carries one, so every pipeline stage died immediately when users configured coarse with `anthropic/claude-opus-4-7`; switching back to 4.6 was the only workaround. `litellm.drop_params=True` couldn't catch this because litellm's per-provider "supported_openai_params" table is indexed on provider family (Anthropic), not on specific model IDs — the Anthropic family still lists temperature as supported, so the drop gate never fired. Fix: new `TEMPERATURE_UNSUPPORTED_PREFIXES` tuple + `supports_temperature(model_id)` helper in `src/coarse/models.py`, plumbed through `LLMClient.complete()` and `LLMClient.complete_text()` in `src/coarse/llm.py` so the `temperature` kwarg is omitted entirely (not sent as `None` — Anthropic's `/v1/messages` validates `null` as a bad request) when the target model is on the list. Every other currently supported model, including Opus 4.6, keeps its existing behavior. Opus 4.7 also added as a default option in `web/src/components/ModelPicker.tsx`. Verified against OpenRouter `/api/v1/models` on 2026-04-19: 4.7's `supported_parameters` list is `['include_reasoning', 'max_tokens', 'reasoning', 'response_format', 'stop', 'structured_outputs', 'tool_choice', 'tools', 'verbosity']` — no temperature — whereas 4.6's list still includes it. New tests in `tests/test_llm.py` (omit-on-opus-4.7 direct + openrouter, forward-on-opus-4.6, complete_text omission) and `tests/test_models.py` (the `supports_temperature` matrix).

- **Modal worker: Supabase Storage download now retries on transient httpx timeouts.** `deploy/modal_worker.py::do_review` made a single unguarded `db.storage.from_("papers").download(pdf_storage_path)` call on line 613, which uses `storage3`'s default httpx client (~20 s total timeout, HTTP/2). Any Modal-to-Supabase network hiccup that crossed that threshold raised `httpx.ReadTimeout` and killed the whole review before the pipeline could even import `coarse` — the row was left in `status=running` forever, hidden from the user until the 3 h `sweep_stale_reviews` cron eventually flipped it to `failed`. Confirmed in Modal logs: 2 `ReadTimeout` incidents in 24 h, 100 % fatal each time. Fix: new `_download_pdf_with_retry(db, pdf_storage_path, job_id)` helper, placed next to the other `db`-scoped helpers (`_resolve_user_api_key`, `_get_review_status`). It retries up to 4 times with exponential backoff (1 s, 2 s, 4 s, capped at 8 s) and logs each failed attempt through the existing `_sanitize_error` scrubber. On exhaustion it raises `RuntimeError` chained from the last httpx exception, so the do_review `except BaseException` handler marks the row failed with a real error message. Also retroactively patched the stuck `775d8877-f033-4920-8d72-f38359d0181a` row to `status=failed` with a user-facing explanation so that one user isn't staring at a dead spinner.

- **Web submit route: race condition between Modal webhook timeout and worker spawn caused ~21% of Modal-backed reviews to fail with `OPENROUTER_API_KEY=MISSING` after ~5 min of Docling OCR.** Modal's `run_review` webhook calls `do_review.spawn()` *before* sending the HTTP response, so a cold-start tail of 9–10s on the previous 10s Vercel `AbortController` caused the submit route's catch block to fire *after* the worker had already been queued. That catch block then deleted the just-inserted `review_secrets` row and called `markFailed()`, so the worker started with no `OPENROUTER_API_KEY` and every LLM call failed with a provider-specific auth error (`litellm.AuthenticationError: DeepseekException`, `Missing Anthropic API Key`, `BadRequestError: LLM Provider NOT provided`, etc.). Forensics from the last 24h of `modal app logs coarse-review`: 100 `do_review` invocations vs 74 logged `POST / -> 200 OK` responses (26 aborted-before-response); 21 `OPENROUTER_API_KEY=MISSING` entries; 9 of 14 affected users *also* had successful reviews (ruling out "never provided a key"). Fix in `web/src/app/api/submit/route.ts`: bump `MODAL_TRIGGER_TIMEOUT_MS` from 10s → 25s (2.5× the observed 9.78s cold-start tail, still 5s below the `maxDuration=30` ceiling); in the `catch` block, stop deleting `review_secrets` and stop calling `markFailed` — the worker unconditionally sets `status=running` on start, and genuinely orphaned rows are swept by the hourly `cleanup_review_secrets` cron + the 15-minute `sweep_stale_reviews` cron. The `!workerResp.ok` branch (Modal returned an explicit 4xx/5xx, so the spawn definitely did not happen) still cleans up. The route now returns 202 with `{id, accessToken, warning}` on timeout so the client's `submitResp.ok` check still passes and it redirects to the status page — which the user would reach anyway as the worker catches up.

## v1.3.0 — 2026-04-15

### Security

- **Five Codex-security findings addressed in one pass.** (1) **Finding #1 + #5 — OpenRouter API key leaked into LLM transcripts.** The three bundled skill docs (`src/coarse/_skills/{claude_code,codex,gemini_cli}/SKILL.md`) and `web/src/lib/mcpHandoff.ts`'s `buildAgentPrompt` instructed the agent to run `echo $OPENROUTER_API_KEY` / `grep OPENROUTER_API_KEY .env` and offered "paste the key into chat" as option 1. Both paths copy the user's API key value into a Claude Code / Codex / Gemini transcript, where it's transmitted to the LLM provider and may be retained in logs. Fix: replace value-printing probes with presence-only probes (`test -n "$OPENROUTER_API_KEY"` / `test -f .env && grep -q '^OPENROUTER_API_KEY=' .env`), drop the "paste it here" option entirely, add an explicit do-not-paste warning with a remediation path (run `coarse setup` yourself), and instruct the agent to refuse if the user pastes a key anyway and tell them to rotate it. (2) **Finding #2 — review UUIDs leaked in public Actions logs.** `.github/workflows/sweep_stale_reviews.yml` (runs every 15 min) enumerated every stale queued/running `reviews` row and logged full UUIDs to stdout. Review UUIDs are the access key for legacy rows (the `migrate_review_access_security` RLS hardening only covers rows carrying `access_token_required=true`), so the public workflow log was a read side-channel. Fix: log only an 8-char prefix per row and emit `::add-mask::<uuid>` before the print so Actions redacts the full UUID if a future edit prints it elsewhere in the job. (3) **Finding #3 — `COARSE_OCR_MAX_RETRIES` env override silently ignored.** The 2e17317 refactor that split extraction into `src/coarse/extraction_openrouter.py` hard-coded `_OCR_MAX_RETRIES = 9` as a module constant and deleted the `_get_ocr_max_retries()` helper that used to read the env var. Any spinner-watching caller that set the env var to short-circuit the default ~160s retry budget was silently ignored. Fix: restore `_get_ocr_max_retries()` at module level (reads the env, clamps to ≥0, falls back to the default on malformed values), bind the ceiling once at call time inside `_post_openrouter_ocr`, and use that local variable through the retry loop. Two new tests pin both the env-override happy path and the fall-back-to-default behaviour for malformed / empty values. (4) **Finding #4 — host-header poisoning via `x-forwarded-host` in the CLI handoff route.** `web/src/app/api/cli-handoff/route.ts` rewrote a localhost `siteUrl` to `https://${request.headers.get("x-forwarded-host")}` with no allowlist, signature, or dev-mode guard. The header is attacker-controllable in most proxy setups, and the downstream `/h/<token>` endpoint serves a 3-hour signed PDF URL plus the single-use finalize token — so a crafted header could poison the handoff URL returned to the UI and let an attacker intercept the paper and write the review. Fix: delete the localhost→`x-forwarded-host` rewrite branch entirely. Remote-CLI dev flows already have a dedicated path via `NEXT_PUBLIC_SITE_URL`, and the existing response warning points operators at that env var. A new Python-side regression test (`tests/test_web_security_invariants.py`) reads the TypeScript sources verbatim and asserts the unsafe strings (value-printing probes, "paste it here", `forwardedHost` interpolation) are gone — cheaper than wiring a JS test runner into the web package for three assertions.

### Added

- **Automated preview Supabase migration runner.** New `scripts/apply_migrations.sh` applies every `deploy/migrate_*.sql` exactly once per project, tracked in a `schema_migrations (name text primary key, applied_at timestamptz)` table the script creates on first use and reads on every subsequent run to skip already-applied files. New `.github/workflows/supabase-migrate-preview.yml` fires on any `dev` push that touches `deploy/migrate_*.sql`, `scripts/apply_migrations.sh`, or the workflow itself, installs `postgresql-client`, reads the `PREVIEW_SUPABASE_DB_URL` secret from the `preview-modal` GitHub Environment, and runs the script against preview Supabase before the paired `modal-preview-deploy.yml` run so preview Modal workers never boot against an unmigrated DB. New `deploy/bootstrap_schema_migrations.sql` is a one-shot the operator runs in the Supabase SQL editor once per project, seeding the tracking table with every historical migration already applied by hand during initial setup. This exists because several of those files are deliberately destructive (e.g. `migrate_email_phase2.sql` does `alter table reviews drop column email`) and would crash if replayed; after bootstrap, the tracking table guarantees they're skipped on every CI run while new migrations added later apply exactly once. Behavioral coverage via a mock-psql harness: fresh run applies all, second run skips all, dry-run shows pending without mutating state, failure mid-run stops the loop and exits 1 with the tracking table reflecting only pre-failure successes, and a bootstrap-seeded run applies only the one un-seeded migration. **Operator action required once:** run `deploy/bootstrap_schema_migrations.sql` in the preview Supabase SQL editor, then `gh secret set PREVIEW_SUPABASE_DB_URL --env preview-modal --repo Davidvandijcke/coarse --body "<connection-string>"` using the preview project's Connection String (URI tab) from Supabase → Settings → Database. Same steps apply to prod whenever a matching prod-migrate workflow is wired up.

- **`deploy/migrate_rename_handoff_tokens.sql` — rename `mcp_handoff_tokens` → `handoff_tokens`** and `cleanup_mcp_handoff_tokens()` → `cleanup_handoff_tokens()`. The table holds CLI / subscription-handoff finalize tokens (3-hour TTL, one row per paper); its `mcp_` prefix was a historical accident from when the same table briefly served the now-retired MCP server path. The name confused a later cleanup commit into dropping the table as MCP scaffolding (see the matching Fixed entry); renaming it removes the trap. Migration is idempotent end-to-end: it renames the legacy table in place when present (preserving prod data + the FK to `reviews.id`), creates `handoff_tokens` fresh on any project where the legacy is already gone, installs the new cleanup function, drops the old one, and fires `notify pgrst, 'reload schema'` so Supabase PostgREST picks up the rename without waiting for its periodic refresh.

### Fixed

- **CLI / subscription handoff (`Review with my subscription`) returned HTTP 500 `Failed to mint finalize token: Could not find the table 'public.mcp_handoff_tokens' in the schema cache` on preview.** PR #133 (commit 8fd3720) retired the `mcp_handoff_tokens` table on the premise that "no runtime code references this table anymore" — but `web/src/app/api/cli-handoff/route.ts`, `web/src/app/h/[token]/route.ts`, and `web/src/app/api/mcp-finalize/route.ts` all still read/write it end-to-end, because v1.3.0 (d0706b2) deliberately kept the table when it retired the MCP server path. The new `supabase-migrate-preview` runner ran the drop migration against preview Supabase, which silently broke the subscription handoff for every user who clicked the launch button. Production was shielded only by the accident that no prod-migrate workflow exists yet, so prod still held the legacy table at the time of this fix. Three-part repair: (1) **delete `deploy/migrate_drop_mcp_handoff_tokens.sql`** so the preview / prod runners never replay the bad drop; (2) **add `deploy/migrate_rename_handoff_tokens.sql`** (see Added) which renames the legacy table in place on prod and creates `handoff_tokens` fresh on preview where the drop already emptied it; (3) **point every TS route and test at the new table name** (`handoff_tokens`) and the new cleanup function (`cleanup_handoff_tokens`). Root cause of the original misread was the table name itself — the `mcp_` prefix tricked a well-meaning cleanup into thinking the table belonged to the retired MCP path. The rename eliminates the trap so the next cleanup pass can't make the same mistake. **Operator action on release to prod:** after the release PR lands on `main`, run `DATABASE_URL=<prod> scripts/apply_migrations.sh` against the production Supabase project (bootstrap the tracking table first if it does not already exist) so the rename applies to prod before the production Vercel redeploy picks up the new table name. `alter table rename` is atomic and keeps every in-flight finalize token intact.

- **`deploy/supabase_schema.sql` status constraint was inconsistent with the `count_active_submitted_reviews` RPC** that lives in the same file. The table definition restricted `reviews.status` to `(queued, running, done, failed, cancelled)`, but the RPC at the bottom of the file filters `r.status in ('queued', 'running', 'extracting', 'extracted')` — two statuses the check constraint would have rejected at insert time. Current preview and prod Supabase projects are unaffected because both were bootstrapped from the older baseline and then had `migrate_mcp_handoff.sql` applied (which widened the constraint to include the extracting/extracted statuses), but any fresh Supabase project bootstrapped from `supabase_schema.sql` in its current state would have failed the first time a paper was handed off to the CLI and transitioned to `status='extracting'`. Bug was introduced in `d0706b2 feat(release): retire MCP server path entirely (v1.3.0)` when the baseline was rebuilt but the widened constraint block was dropped without being re-inlined. Fix: baseline constraint now lists all seven current statuses (`queued, running, extracting, extracted, done, failed, cancelled`) so a fresh-project bootstrap matches the running preview/prod state.

### Removed

- **`deploy/migrate_mcp_handoff.sql`** — reaped as part of the MCP server retirement (`d0706b2`). The file originally created the `mcp_handoff_tokens` table along with three load-bearing operations (`review_handoff_secrets` table, `reviews.taxonomy` column, widened `reviews_status_check`). The three load-bearing bits were inlined into `supabase_schema.sql` by the v1.3.0 retirement commit (with the exception of the widened status check, tracked as the bug above), so a fresh bootstrap no longer needs this file at all. The `mcp_handoff_tokens` table itself is **not** dead — it's renamed to `handoff_tokens` by `deploy/migrate_rename_handoff_tokens.sql` and still carries the CLI / subscription handoff flow. `deploy/bootstrap_schema_migrations.sql`'s seed list was updated to drop `migrate_mcp_handoff` accordingly.

- **`deploy/migrate_drop_mcp_handoff_tokens.sql`** — the drop migration from PR #133 (commit 8fd3720) was added on the incorrect premise that the `mcp_handoff_tokens` table had no runtime callers. Deleted before any prod run ever saw it; its preview run is compensated for by the `if not exists` create in the new rename migration. See the matching Fixed entry for the full incident writeup.

- **`web/src/lib/supabase.ts` — dead anon-key browser client factory with zero importers.** The file exported a `createClient()` helper that instantiated `createBrowserClient(NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY)` for direct-from-browser Supabase access. It was a leftover from an earlier design iteration; the current web app uses only server-side routes with `SUPABASE_SERVICE_KEY` for every Supabase operation, so nothing ever imported the factory. Caught during the issue #134 drift audit — the file's existence was the last thing standing between "drop the permissive RLS policy on prod" and "this is definitely safe" because if any browser code had been using the anon-key factory, the RLS drop would have broken it. Confirmed zero importers via repo-wide grep, then deleted.

### Fixed

- **Headless CLI transport hardening round 2 — retry loop, model fallback, concurrency semaphore, skill version sentinel, large-prompt test.** Five MEDIUM/LOW-severity gaps from the post-probe integration audit, on top of the HIGH-severity round-1 fixes in commit edb6dde. (1) **Retry loop**: `_HeadlessCLIClient._run` now wraps `subprocess.run` in a 3-attempt retry with 2s/4s backoff between attempts. The new `_is_transient_cli_error(stderr, returncode)` classifier catches rate-limit, 429/502/503/504, connection reset, upstream overloaded, "please try again", and GNU timeout(1) exit 124 patterns; matching errors retry, everything else fails fast. Auth failures, `FileNotFoundError` (missing binary), Python-level timeouts, and generic permanent errors all skip the retry so we don't loop on truly broken calls. Previously a single transient rate-limit during a 20-section parallel review would kill the entire 25-minute pipeline — now the worst case is a 6-second detour per failing call. (2) **Unknown-model fallback**: `_is_unknown_model_error(stderr)` catches "unknown model" / "model not found" / "invalid model" patterns, and each client's new `_can_fall_back_to_default_model()` / `_fall_back_to_default_model()` hooks swap the user-provided model for the class default (`HEADLESS_DEFAULT_MODELS[host]`) and retry once. Instance-scoped `_model_fallback_attempted` flag caps the fallback at one shot so a broken default doesn't loop. Codex and Gemini skip the fallback when the user didn't provide an explicit model (they already default to the CLI's built-in choice). (3) **Concurrency semaphore**: each client class owns a `threading.Semaphore(N)` via the new `_HeadlessCLIClient._get_semaphore` classmethod, lazily initialised on first use with double-checked locking. Default `N=3`, overridable via the new `COARSE_HEADLESS_CONCURRENCY` env var (validated to 1-20, invalid values fall back to default with a warning). `_run` acquires the semaphore via `with type(self)._get_semaphore():` so at most 3 `claude -p` subprocesses run simultaneously even when the pipeline spawns 10 parallel section agents — prevents the "20-section paper trips Claude Max rate limit at section 11" failure mode. Per-class (not global) so mixed-host runs don't cross-contend. (4) **Skill version sentinel**: `coarse install-skills` now writes a `.coarse-version` file next to each `SKILL.md` containing `__version__`. A second install with a different version logs `✓ claude → <dest> (replaced 1.3.0 → 1.4.0)` so users know their local skill bundle was refreshed. First-install paths and identical-content paths stay silent. New `_read_skill_version(dest)` helper reads the sentinel without crashing on permission errors. (5) **Large-prompt regression test**: new `test_run_handles_large_prompt_stdin` pipes a 500 KB synthetic prompt through `_run` with a mocked `subprocess.run` that asserts the full input arrived — pins the current behaviour so a future switch away from `subprocess.run(..., input=prompt)` to explicit `Popen` can't silently lose bytes. 19 new tests: transient-error classifier coverage (positive + negative), retry loop (success after 2 transient failures, exhaustion on persistent failure, no-retry for permanent/auth/syntax errors), model fallback (success + one-shot cap), Codex / Gemini skip-fallback when no user model, concurrency env-var parsing (valid / invalid / out-of-range), semaphore actually caps parallelism, install-skills sentinel write + replacement notice + first-install no-notice. `KNOWN_OVERSIZED['headless_clients']` cap bumped from implicit 800 → 1100 to accommodate the ~280 LOC of new retry/semaphore/fallback infrastructure; post-v1.3.0 candidates for splitting into per-client modules. Full `uv run pytest tests/` = 931 passed.

- **Headless CLI transport hardening round 1 — UTF-8, missing-binary, auth-expiry.** Three HIGH-severity gaps in `src/coarse/headless_clients.py::_HeadlessCLIClient._run` flagged by a post-probe integration audit. (1) **UTF-8 decoding**: `subprocess.run(..., text=True, ...)` relied on `locale.getpreferredencoding(False)` for stdout decode, which is `cp1252` on default Windows and crashes with `UnicodeDecodeError` the first time a claude/codex/gemini response contains a Greek letter, math symbol, em-dash, or emoji. Now passes `encoding="utf-8", errors="replace"` to both `subprocess.run` call sites (`_run` and the `_probe_cli_help` helper). Also forces `LANG=C.UTF-8` and `LC_ALL=C.UTF-8` (or `en_US.UTF-8` on macOS) in `_clean_subprocess_env` so Node-based child CLIs write UTF-8 to stdout deterministically regardless of the user's shell locale; preserves any caller-set `LANG`/`LC_ALL` override. Windows path is a no-op since Node picks up the console codepage. (2) **Missing-binary error**: `FileNotFoundError` from `subprocess.run` when the host CLI binary isn't on PATH used to bubble up as an opaque `[Errno 2] No such file or directory: 'claude'` traceback 10 pipeline stages into a review. Now caught in `_run` and re-raised as a `RuntimeError` with a host-specific install hint: `"claude -p binary not found on PATH. Install it with: npm install -g @anthropic-ai/claude-code@latest"`. Each subclass overrides the new `_install_hint()` method so `CodexClient` points at `@openai/codex` and `GeminiClient` at `@google/gemini-cli`. (3) **Auth-expiry detection**: when the host CLI exits non-zero with "not authenticated" / "unauthorized" / "session expired" / "run `claude login`" / similar in stderr, the wrapper now classifies the error via `_classify_cli_error` and prepends a user-facing hint like `"claude -p auth failure. Run `claude login` ... to re-authenticate. Raw error: ..."`. Substring-matched (not regex) so version-drifting stderr formats still catch. Non-auth failures fall through to the existing generic `"exited N: <stderr>"` path unchanged. Nine new tests pin: UTF-8 round-trip through `_run` with Greek/math/em-dash/emoji, LANG/LC_ALL forcing on Unix + Windows skip, LANG override preservation, `FileNotFoundError` → friendly error per host, auth-pattern classifier coverage (positive + negative cases), and non-auth fallthrough. Full `uv run pytest tests/` = 912 passed.

- **Headless CLI clients now probe for version-gated flags and fall back gracefully on older installs.** Florian hit this on v1.3.0 preview: his `claude -p` version predates `--effort`, so every pipeline stage died with `error: unknown option '--effort'` and produced zero output. Fix: `ClaudeCodeClient` now probes `claude -p --help` once per class on first use, caches whether `--effort` is mentioned, and either passes the native flag (new versions) or drops it and prepends an effort-guidance block to the prompt (old versions). Same pattern applied preemptively to `CodexClient` (probes `codex exec --help` for `-c KEY=VALUE` config-override support and falls back to text injection when missing) and `GeminiClient` (probes `gemini --help` independently for `--approval-mode` — falls back to the legacy `--yolo` toggle — and `--output-format text` — drops it entirely when missing, since Gemini's default is already text). Effort-guidance text uses a single shared helper `_effort_text_prefix(effort)` so old-version fallback behavior is identical across all three clients. Probe results are cached on each class so `claude --help` runs at most once per process. Probe failures (missing binary, timeout, permission error) default to "unsupported" so the pipeline degrades gracefully instead of crashing on an unknown-flag error at stage 1. Six new tests in `tests/test_headless_clients.py` cover: Claude old-version drops `--effort` + injects text, Claude probe caches across instances, Codex old-version drops `-c model_reasoning_effort`, Gemini old-version falls back to `--yolo`, Gemini mixed version (has `--approval-mode` but not `--output-format`) picks each flag independently, and a `_reset_probe_caches` autouse fixture so test isolation is guaranteed. The existing `test_claude_effort_passes_through_unchanged` was updated to pre-set the probe cache to `True` and to assert the new flag ordering (`--output-format` before `--effort`). Full `uv run pytest tests/` = 903 passed.

- **OpenRouter file-parser 400 on large PDFs (≥ ~15 MB)** — `_extract_openrouter_file_parser` in `src/coarse/extraction_openrouter.py` base64-encoded the whole PDF inline into the JSON request body to OpenRouter. OpenRouter's request-body limit (effective ~8–16 MB on its Cloudflare Workers path) rejected anything larger with a generic HTTP 400 well before Mistral OCR ever saw the file, even though Mistral's own limits are 50 MB / 1000 pages. A real user hit this with a 20 MB paper. Fix: introduce a new `signed_url_ctx: ContextVar[str | None]` at module level — when set, `_extract_openrouter_file_parser` sends the signed URL straight through in the `file_data` field so OpenRouter fetches the PDF server-side and hands it to the backend engine without the base64 blob ever riding in the request body. The contextvar is set in two call sites: `src/coarse/cli_review.py` reads `signed_download_url` from the handoff bundle and sets the ctx around `run_headless_review` (reset in a `finally` block so no leakage across invocations), and `deploy/modal_worker.py` mints a short-lived (900 s) Supabase signed URL via `db.storage.from_("papers").create_signed_url(...)` immediately after the storage download and sets the ctx around `review_paper` (same `finally` reset). Both call sites fall back to the original base64 path when minting fails or the bundle is missing the URL, matching pre-fix behaviour exactly — a regression is impossible. The local download in `_download_handoff_source` still happens because `extraction_qa.py` needs the PDF on disk for the vision-LLM page-render check; we just stop base64-encoding it for OpenRouter. Signed URL TTL of 900 s gives the retry loop in `_post_openrouter_ocr` (9 attempts, exponential backoff, ~288 s max wall clock) roughly 3× headroom. Tests: two new tests in `tests/test_extraction_openrouter.py` cover URL mode (asserts `file_data` is the URL literal and contains no base64 prefix) and the contextvar reset path (base64 fallback restored after `.reset()`), plus the existing base64 test confirms the default path when the ctx is unset. Full `uv run pytest tests/` still green at 897 passed.

### Major release — the big-ticket items

- **Subscription handoff flow** — new `coarse-review --handoff <url>` command + `coarse install-skills` CLI + bundled Claude Code / Codex / Gemini CLI skill templates. Users paste one command into their coding agent and it runs the full review pipeline against their own Claude Code / Codex / Gemini CLI subscription, paying zero LLM API cost (only ~$0.15 OpenRouter extraction). Signal-driven `--attach` watcher replaces the legacy poll-based flow. Preview-first deploy workflow with Tier 0 + Tier 1 isolation docs.
- **MCP server** — new `deploy/mcp_server.py` exposes the coarse pipeline as an MCP server on Modal (`coarse-mcp` app) so Claude Desktop / Claude.ai connectors can review papers directly. Single-container session store for now (low-volume production); scaling path documented in `deploy/DEPLOY_MCP.md`.
- **Web hardening** — migrated transactional email from Gmail SMTP to Resend, Cloudflare Turnstile gate on the submit flow, fail-closed submission kill switch, session-scoped OpenRouter OAuth key storage, fenced prompt boundaries on all untrusted paper content paths, Modal webhook validation, preview-first release workflow with environment-scoped Vercel Authentication + middleware Basic Auth fallback.
- **Pipeline refactor** — extraction split by responsibility, stage helpers moved out of orchestrator, canonical pipeline stage spec, architecture anti-drift regression tests that statically scan import graphs for cycles / layer violations / oversized files.

Total: **87 commits** from 131 PRs + direct-to-dev fixes, **~21k insertions / ~2.4k deletions** across 119 files. Every line of the subscription-handoff flow is covered by the handoff-polish chain (#121-#131) documented below.

### Changed

- **Setup page: spend-limit suggestion bumped from $10 to $20, plus a new "Use my subscription" tab for the coding-agent handoff path.** Two user-reported issues:
  1. The original $10 suggestion was too close to real review cost for complex papers; a single review could exhaust the key mid-run and waste the whole spend. Step 2 (Add credits) and Step 4 (Credit limit) now both recommend $20 as the minimum, with an explanation that the cost estimate shown pre-submit is a heuristic with ~15% buffer, not a hard ceiling. The bump gives every review ~10x headroom on top of the typical $0.25–$2 cost.
  2. The setup page only covered the direct-OpenRouter flow. Users hitting "Review with my subscription" from the main form had no walkthrough of the prerequisites (install Claude Code / Codex / Gemini CLI first, log into the subscription, add minimal OpenRouter credit for the OCR pass only). Added a tab switcher (`TabSwitcher` / `OpenRouterTab` / `SubscriptionTab` in `web/src/app/setup/page.tsx`). The **OpenRouter key** tab holds the existing 5-step flow with the $20 bump. The new **Use my subscription** tab has 5 new steps: install one of the three coding agents with vendor-docs links + login/test commands per agent (`AgentCard` component), get $1 of OpenRouter credit for extraction only, upload the paper, run the three terminal commands (setup + launch + wait), and a troubleshooting section covering the three failure modes we actually saw during live testing: the "Open Claude Code" button not launching anything on Windows, the `install-skills` "no such command" graceful-degradation path, the `ANTHROPIC_API_KEY` subscription-vs-API billing footgun (fixed in v1.3.0 via `_SUBSCRIPTION_BILLING_KEYS`, but worth a note for anyone upgrading from an earlier `coarse-ink` install), and the "fewer comments than expected" section-timeout symptom. The copy is written in a direct, concrete voice with specific numbers and imperatives rather than vendor-site marketing prose. `/module-review --changed` uncovered three checklist rules that had drifted from the code during the headless-CLI / review-stages refactors, producing false-positive findings. Rule #5 previously said "`print()` is only allowed in `cli.py` top-level flow" — now correctly lists the four CLI entry-point modules that stream stdout to the user (`cli.py`, `cli_review.py`, `cli_attach.py`, `headless_review.py`), matching the split introduced when `--attach` moved out of `cli_review.py`. Rule #6 now explicitly calls out that back-compat re-export shims (`claude_code_client.py`) still need dedicated `is`-identity tests, pinning the pattern used for the new `tests/test_claude_code_client.py`. Rule #9 now carves out env-passthrough in CLI entry modules (`os.environ["OPENROUTER_API_KEY"] = ...` for subprocess inheritance, and `_find_openrouter_key`'s lightweight no-config-dep key discovery in `headless_review.py`) so the rule stops flagging intentional bootstrap code. Rule #11 was the most out-of-date: it told reviewers that individual agents had to call `quote_verify.verify_quote` before returning, but the actual design centralizes verification in `review_stages._verify_with_fallback`, which wraps every comment-producing stage (section, proof_verify, editorial, crossref, critique). The updated rule documents the wrapper as the canonical call site and flags new agents or pipeline paths that bypass it. Both `.claude/commands/module-review.md` and `.Codex/commands/module-review.md` are kept in sync so the Claude Code and Codex runs of `/module-review` apply the same rules.

### Added

- **`make release-audit` + runtime safety net for the dev → main release cutover.** The preview gate, bypass secret, preview Supabase URL, and preview Modal environment are all environment-scoped — production cold-starts never see any of it because every consumer checks `VERCEL_ENV === "preview"` first. In theory `dev → main` needs no code change. In practice the dashboard side can drift, so this commit adds three layers that make the cutover bulletproof: (1) **A new `scripts/release_audit.py`** (runnable via `make release-audit`) that greps `web/src` for any `process.env.PREVIEW_BASIC_AUTH_PASSWORD`, `process.env.PREVIEW_BASIC_AUTH_USERNAME`, or `process.env.VERCEL_AUTOMATION_BYPASS_SECRET` read that isn't within 10 lines of a `VERCEL_ENV === "preview"` guard (string literals that merely mention the variable name are skipped via a `process\.env\.` match anchor), fails exit 1 on any unguarded read, and then prints a manual dashboard checklist covering Vercel production env vars (must-have + must-not-have), Modal production secrets, Supabase migrations, and the final tag-and-push sequence. (2) **A runtime safety net** in `web/src/middleware.ts::warnIfPreviewVarsLeakedIntoProduction()` that runs once per Vercel cold start, checks whether any of the three preview-only env vars is set while `VERCEL_ENV === "production"`, and logs a loud `[release-audit]` `console.error` naming the leaked var(s) so Vercel runtime logs surface a dashboard misconfiguration that would otherwise be silent. The preview code paths themselves still no-op on production (every consumer gates on `VERCEL_ENV === "preview"` first), but the warning makes the misconfig visible instead of silent. (3) **A CI-time drift guard** in `tests/test_release_audit.py` with four tests: `test_release_audit_clean_on_dev` (asserts the current web source passes the audit), `test_release_audit_detects_injected_unguarded_read` (positive control), `test_release_audit_accepts_string_literal_mentions` (negative control), `test_release_audit_accepts_guarded_read` (negative control). The test suite pins both the detector's precision AND the current state of the web source, so any future PR that introduces an unguarded read fails CI with a pointer at the exact file:line to fix. A new **"Cutting a production release (dev → main)"** section in `deploy/PREVIEW_ENVIRONMENTS.md` explains the separation of concerns and references both the audit script and the runtime safety net. The section sits between Tier 0 and Tier 1 because it applies regardless of which preview infrastructure tier the operator has adopted.

### Removed

- **Retired the entire MCP server path.** The `coarse-mcp` Modal app, `deploy/mcp_server.py`, `deploy/mcp_test_client.py`, the `/mcp` landing page, `/api/mcp-extract`, `/api/mcp-handoff`, `scripts/run_remote_mcp_dev.py`, `tests/test_mcp_server.py`, `tests/test_remote_mcp_dev.py`, `docs/mcp-install.md`, the `mcp-ingest` CLI command in `src/coarse/cli.py`, and the `[mcp]` optional-dependency extra (`fastmcp`, `pymupdf4llm`) in `pyproject.toml` are all gone. Rationale: the MCP server was supposed to let users run a review against their Claude.ai / ChatGPT / Claude Code / Gemini CLI subscription by exposing our pipeline as MCP tools. But the architecture collapses our parallel multi-stage pipeline into a single sequential chat session inside the host LLM — no parallel section reviews (that's tool-call parallelism, not multiple Claude instances), no multi-model routing, no orchestrated editorial/critique/quote-verify stages, no cost gate, no structured output enforcement. The resulting review was strictly worse than what the real pipeline produces. Meanwhile the subscription-handoff flow (#121–#131, landed in v1.3.0) runs the **actual coarse pipeline** against `claude -p` / `codex exec` / `gemini -p` subprocesses with full parallelism, on the user's own machine, billing their subscription directly. It's the same use case — "run reviews on my subscription instead of paying OpenRouter" — and it's genuinely better. The MCP server also had a single-container bottleneck (`min_containers=1, max_containers=1` because FastMCP's session store is in-memory) that would have needed Redis or Postgres session persistence to scale. Instead of fixing that to keep a strictly worse path alive, we deleted it. What stays: `/api/mcp-finalize` is retained because it's the subscription-handoff callback (misleading name kept for migration continuity — the "mcp" in the name is historical, not architectural). `web/src/lib/mcpHandoff.ts` is retained because it holds the subscription-handoff helpers. Drift tests in `tests/test_headless_instructions.py`, `tests/test_handoff_lifetimes.py`, and `tests/test_kill_switch.py` were updated to drop the retired routes from their pinned exempt-list / route-set assertions. `scripts/release_audit.py` dashboard checklist no longer requires `MODAL_EXTRACT_URL` or `coarse-mcp` in production Modal. `deploy/DEPLOY.md` + `deploy/PREVIEW_ENVIRONMENTS.md` + `CLAUDE.md` + `AGENTS.md` got v1.3.0-retire banners at the top and the workflow files (`modal-deploy.yml`, `modal-preview-deploy.yml`) dropped the `coarse-mcp` matrix entry. **Operator action required after merging:** run `modal app stop coarse-mcp` (and `modal app stop coarse-mcp -e preview`) to tear down the deployed Modal apps — they're no longer referenced by CI but the Modal platform keeps running them until you explicitly stop them.

### Fixed

- **Pre-PR 5-agent review sweep on the release branch.** Ran `/pre-pr` on the release branch before cutting the tag; the 5 parallel review agents flagged 2 CRITICAL test gaps, 15 HIGH findings, and 23 MEDIUM items. Addressed the critical/high items that could affect end users without blocking the release:
  1. **Preview bypass secret + signed URLs scrubbed from CLI error messages.** `cli_review.py::_fetch_handoff`, `_download_handoff_source`, `_post_finalize`, and the top-level `main()` exception printer now route every URL and response-body interpolation through a new `_scrub_url()` helper that strips query strings before the URL hits stderr or the detached log file. The security-review agent caught this: `_post_finalize` was re-raising `RuntimeError(f"Callback to {callback_url} failed: ...")` and on preview deploys `callback_url` includes `?x-vercel-protection-bypass=<VERCEL_AUTOMATION_BYPASS_SECRET>` — the bypass secret was landing in the detached log file agents routinely grep. Fix preserves the scheme/host/path so the error is still useful for the human reading it.
  2. **Harmonized `signed_download_url` as the single canonical field name.** The `/h/[token]` route was emitting `signed_pdf_url` only; the CLI's `_fetch_handoff` was reading `signed_download_url` as primary with a fallback to the legacy name. In production the primary branch was always dead code and the `or`-fallback did all the real work. Tests mocked the new name — so the test suite exercised a code path that cannot fire in production. Renamed the server emission to `signed_download_url`, deleted the dead fallback and its `setdefault` cargo-cult. One name, one test path, one code path.
  3. **Deleted duplicate `_verify_with_fallback` helper.** `pipeline.py` and `review_stages.py` both defined the helper verbatim as a leftover from #100's pipeline/stage split. Canonical home is now `review_stages.py`; `pipeline.py` imports it. Every call site already passed `drop_unverified` explicitly so the default divergence didn't affect behavior, but the duplicate would silently drift on the next refactor.
  4. **`_post_finalize` retries on transient failures.** Zero-retry was losing the whole 10-25 min review on a single transient 5xx or connection error. Added a 3-attempt exponential backoff loop (2s, 4s, 8s) for connection errors, timeouts, 429, and 5xx. Permanent 4xx failures (400/401/403/404/410) still fail fast because retrying those cannot help. Token consumption is server-side atomic (`UPDATE ... WHERE consumed_at IS NULL`), so retrying on success-but-network-blipped is safe — either the first attempt persisted and the retry gets 410, or it didn't and the retry gets a fresh 200.
  5. **Handoff tempdir + fetch error-handling reshaped.** Previously `_fetch_handoff` and `_download_handoff_source` ran outside any try/except, so a stack trace from a `requests.ConnectionError` or similar would bubble up with the full signed URL in its `__str__`. And the pipeline-failure path (`return 6`) bypassed the callback-failure `finally` and leaked `handoff_tmpdir`. Restructured `main()` with a single outer try/finally wrapping from `mkdtemp` through the final return so the tempdir is cleaned up on every exit path, with catch-and-scrub around the fetch and download individually so no raw exception propagates past the CLI boundary.
  6. **`scripts/kill_switch.py` `urlopen(timeout=15)`.** Stalled Supabase edge was hanging the operator indefinitely. Interactive tool; 15s gives up fast enough.
  7. **`web/src/lib/estimateCost.ts` fetch with `AbortController(10_000)`.** Matches the pattern every other server→upstream fetch in the web surface uses (`turnstile.ts`, `mcp-extract/route.ts`, `submit/route.ts`). Without it, a hung openrouter.ai upstream would pin a Vercel function slot.
  8. **Windows `_windows_pid_alive` disambiguates the `STILL_ACTIVE == 259` collision.** Previously `GetExitCodeProcess` alone treated any process that legitimately exited with code 259 as still-running. Added a follow-up `WaitForSingleObject(handle, 0)` call — `WAIT_TIMEOUT` means still running, `WAIT_OBJECT_0` means signaled/exited. The `OpenProcess` access mask now ORs in `SYNCHRONIZE` so `WaitForSingleObject` has the right grant. The existing `_FakeKernel32` mock in `tests/test_cli_attach.py` gained a `WaitForSingleObject` method and a fifth test case that pins the collision-disambiguation path.
  9. **`O_NOFOLLOW` on config.py + extraction_cache.py atomic opens.** `_save_cache` previously did `is_symlink()`-check-then-open (TOCTOU window); `save_config` didn't check at all. Both `os.open` calls now OR in `O_NOFOLLOW` (when available — guarded via `hasattr` for exotic POSIX variants) so the kernel refuses the open atomically if the path resolves to a symlink. Closes the last of the file-creation hardening items from the security review.
  10. **`/h/[token]/route.ts` uses `.maybeSingle()` not `.single()`.** `.single()` treats zero rows as an error, collapsing "token not found" (legitimate 404) and "DB timeout" (500) into the same 404 message and losing diagnostic value. Now explicitly distinguishes the two — symmetric with `/api/mcp-finalize`'s pattern.
  11. **`tests/test_architecture_drift.py::KNOWN_OVERSIZED` tightened.** Was a `set[str]` that let allowlisted modules grow unchecked — `prompts.py` had drifted from ~1500 to 2453 lines under cover of the allowlist. Now a `dict[str, int]` with an explicit per-module ceiling that fails the test if any allowlisted module grows past its recorded cap. `extraction` dropped from the allowlist entirely — it's 291 lines now, well under the 800-line default cap, and keeping it in the allowlist was a false positive that would let its successor balloon.

  Items deliberately deferred to v1.3.1 (need design work or large new test surface, not runtime bugs): global `LLMClient` monkey-patch unwinding in `headless_review._patch_llmclient`, `deploy/mcp_server.py` reaching into private `coarse.prompts` internals, the duplicate extraction/QA block between `review_paper` and `extract_and_structure`, MCP `_STORE` persistence across container recycle, timing-safe compare on preview middleware Basic Auth, SSRF allowlist on `mcp_server.py::upload_paper_url`, and a proper end-to-end `run_headless_review` test at the function level. Each is documented in the pre-PR review comments; none are load-bearing for the v1.3.0 release.

- **Security review follow-up: three MEDIUM findings from `/security-review` on `dev` @ 497df23.** (1) `src/coarse/config.py::save_config` now creates `~/.coarse/config.toml` with `os.open(path, O_WRONLY|O_CREAT|O_TRUNC, 0o600)` + `os.fchmod(fd, 0o600)` instead of `open(path, "wb")` followed by `path.chmod(0o600)`. The old sequence opened a TOCTOU window where a co-tenant UID on the same machine could `open()` the freshly-created file under the umask-default mode (typically `0o644`) before the `chmod` landed and read every provider API key inside. The new sequence never exists on disk at anything other than `0o600`. Windows still falls back to the old chmod-after-write pattern because `os.open` ignores the mode bits there. (2) `src/coarse/extraction_cache.py::_save_cache` gets the same treatment for the `.extraction_cache.json` file that sits next to extracted papers. PaperText is not a secret by contract, but the cache file was deliberately locked to `0o600` for defense-in-depth and had the identical race. (3) `web/src/app/api/mcp-extract/route.ts` now wraps the fire-and-forget Modal `run_extract` POST in an `AbortController` with `MODAL_TRIGGER_TIMEOUT_MS = 10_000` — matching the pattern `/api/submit` already uses for the review worker. Without the timeout, a hung Modal endpoint would keep the Vercel function socket open well past the 202 response and slowly leak function slots under load. The `.catch(err)` branch now distinguishes `AbortError` from other fetch failures and writes a more specific `error_message` ("Extraction worker did not respond in time") to the review row. No CRITICAL or HIGH findings were flagged in the review; these three MEDIUMs are the only blocking-on-next-release items.

- **Centralize headless-CLI default model IDs in `models.py`.** `/module-review --changed` flagged a hard-rule violation: `_DEFAULT_MODELS = {"claude": "claude-opus-4-6", "codex": "gpt-5.4", "gemini": "gemini-3.1-pro-preview"}` was duplicated verbatim between `src/coarse/cli_review.py` and `src/coarse/headless_review.py`, and `src/coarse/headless_clients.py::ClaudeCodeClient.__init__` had a bare `claude_model: str = "claude-opus-4-6"` default argument. CLAUDE.md's single-source-of-truth rule for model IDs says every `"provider/name"` literal outside `src/coarse/models.py` is a violation — these four literals made model bumps a three-file grep-and-replace dance that would silently skip the stale one. Added `HEADLESS_DEFAULT_MODELS: dict[str, str]` to `src/coarse/models.py` and imported it from all three consumers, so changing the default Claude/Codex/Gemini model for the headless CLI stack is now a one-line edit in the manifest. Also dynamically builds the `--model` help text in `headless_review.py` from the dict instead of hardcoding the canonical-model list in a docstring that would rot the next time any default changes.

- **Submission kill switch now covers every spend-starting web path, plus a maintainer one-command toggle.** The existing `system_status.accepting_reviews` gate is now enforced through a shared helper in `web/src/lib/systemStatus.ts` and applied to all routes that can start new paid work: `/api/presign`, `/api/submit`, `/api/mcp-extract`, `/api/cli-handoff`, and `GET /h/[token]`. `/api/mcp-handoff` intentionally stays open so users who already paid for `/api/mcp-extract` can still finish the handoff. The helper now fails closed on `system_status` lookup errors instead of silently allowing new spend, which makes the stop-spend control authoritative during partial outages as well as deliberate pauses. The homepage now refreshes `/api/status` on focus and every 30 seconds so the banner/button state catches up quickly when the switch flips mid-session. For ops, `make pause`, `make resume`, and `make pause-status` now wrap a new stdlib `scripts/kill_switch.py` helper that PATCHes the singleton `system_status` row using the same PostgREST contract the capacity monitor already uses.

- **Pre-PR review round 2: remaining HIGH + MEDIUM items from the 5-agent review.** A second fix-up pass closes every finding the first round deferred. **HIGH #1:** `src/coarse/cli_attach.py::is_pid_alive` now cross-checks the PID's command line via `ps -o command=` and returns False when the cmdline doesn't contain `coarse.cli_review` / `coarse-review` / `coarse/cli_review`. Closes the stale-pidfile-after-hard-crash failure mode flagged by the debug review: if review A crashes without firing its atexit cleanup, a stale `<log>.pid` is left behind; time passes; the OS recycles review A's PID for some unrelated process; the user points `--attach` at the same log. Before this guard the watcher saw `os.kill(pid, 0)` succeed on the recycled PID and hung for the full `--attach-timeout`. After the guard, the recycled process's command line doesn't match any coarse-review marker and the watcher cleanly bails with exit 2. Also tightens the `PermissionError` branch from "treat as alive" to "return False" — our detached worker is always spawned by the current user, so a foreign-user PID there can only mean PID recycling. `_spawn_fake_review` in `tests/test_cli_attach.py` now passes `"coarse-review"` as a trailing argv element so the subprocess's `ps` command line automatically matches the new guard; every existing watcher test keeps working. Three new tests pin the contract: `test_is_pid_alive_on_live_coarse_review_worker_returns_true`, `test_is_pid_alive_returns_false_on_non_coarse_review_pid` (uses the pytest runner as a stand-in for a recycled non-coarse PID), and the pre-existing zombie test. **HIGH #2:** `tests/test_review-stages.py::test_review_section_handles_empty_comments_without_crashing` is the pipeline-integration counterpart to `tests/test_section.py::test_section_agent_accepts_empty_comments_list` — it wires `_review_section` end-to-end with a mocked section agent returning `[]` and asserts the proof-verify chain is correctly short-circuited by the `if focus == "proof" and comments and ...` guard at `review_stages.py:110`. Closes the test-coverage gap flagged by the test-engineer review. **MEDIUM #1:** `web/src/app/h/[token]/route.ts` now SELECTs `reviews.taxonomy` alongside `domain` and surfaces the column value instead of hardcoding `""`. `migrate_mcp_handoff.sql` adds the column, `/api/mcp-finalize/route.ts` writes to it, and now the handoff download path reads it — the column is no longer half-wired. **MEDIUM #2:** Codex / Claude Code / Gemini CLI timeout recommendation bumped from ≥30 min to ≥45 min (`1800000` → `2700000` ms) in `buildAgentPrompt` and all three `SKILL.md` files, with 60-minute guidance for book-length papers or `--effort max` runs. 30 minutes was too tight — the tool timeout is a wall clock, not an idle-stream cap, so a 25-minute review left only 5 minutes of safety margin. **MEDIUM #3:** `tests/test_headless_instructions.py::test_submit_route_handoff_retry_checks_review_status` is a new drift-detection test that pins the 403 retry fix from round 1: asserts `submit/route.ts` contains the `reviewState.status === "failed"` check and the `.select("status, error_message")` lookup, so a future refactor can't quietly revert to silent-success-on-failure. **MEDIUM #4:** `web/src/lib/mcpHandoff.ts` now exports a `buildHandoffLandingCommands(args)` helper that builds the **host-agnostic** uvx + `--detach` + `--attach` command triplet, and `buildCliCommands` layers the `--host/--model/--effort` flags on top. `web/src/app/h/[token]/route.ts::renderLandingPage` imports `buildHandoffLandingCommands` instead of inlining its own command derivation, so the landing-page HTML path and the browser React path can't drift on uvx pin, log-file scheme, or attach-command shape. A new `HandoffCliCommands` interface types both paths. **MEDIUM #5:** `scripts/doc-sync-check.sh` now accepts EITHER `CLAUDE.md` OR `CONTRIBUTING.md` as the canonical home for the package-structure tree, so either file can host it depending on how the contributor docs are organized. `CONTRIBUTING.md`'s tree was refreshed to list every current `src/coarse/*.py` module after the restructure, including `cli_attach.py`, `cli_review.py`, `headless_clients.py`, `headless_review.py`, `pipeline_spec.py`, `review_stages.py`, `extraction_cache.py`, `extraction_formats.py`, `extraction_openrouter.py`, `recall.py`, and the `agents/completeness.py` / `agents/contradiction.py` / `agents/cross_section.py` / `agents/quote_repair.py` that had been missing.

- **Pre-PR review fixes for the `dev` → `main` release PR.** Five findings from the 5-agent pre-PR review were addressed in one commit so the release PR isn't blocked on a long tail of polish: (1) `web/src/app/api/submit/route.ts` no longer returns a fake 200 to clients whose `review_emails` row exists but whose review row is in `status='failed'` — the handoff-secret-already-consumed branch now also SELECTs `reviews.status + error_message` and returns 503 with the original worker-dispatch error when the review genuinely failed. Before this fix, a client retry after a Modal dispatch failure would look successful at the HTTP layer and then show "failed" on the `/status/<id>` page, confusing everyone. (2) `src/coarse/cli_attach.py::run_attach` now guards `sys.stdout.reconfigure(line_buffering=True)` in a `try: ... except (AttributeError, OSError): pass` block — the unwrapped call crashed the whole watcher when stdout had been replaced by an object that didn't expose `reconfigure()` (some CI harnesses, `io.StringIO`, any `contextlib.redirect_stdout(StringIO())` wrapper). Line buffering is nice-to-have, not load-bearing; failing the whole attach over it was wrong. (3) `src/coarse/cli_review.py` handoff mode now tracks the `tempfile.mkdtemp` result in a dedicated `handoff_tmpdir` variable instead of deriving from `temp_source.parent`, and the tempdir cleanup runs in a `try/finally` so the early `return 7` on `_post_finalize` failure no longer leaks `/tmp/coarse-review-*` dirs across invocations. `shutil.rmtree(..., ignore_errors=True)` replaces the old `unlink + rmdir` pair that couldn't survive any future change that landed a second file in the tempdir. (4) `tests/test_headless_instructions.py::test_release_blocker_pin_is_coupled_to_unreleased_version` is a new mechanical guard: while `__version__ == "1.2.2"` (pre-release state) the git-ref pin in `DEFAULT_MCP_UVX_FROM` is allowed; the moment `src/coarse/__init__.py` bumps to any other version, this test FAILS unless the pin has been flipped to `coarse-ink[mcp]=={new_version}`. Catches the exact failure mode the architecture review flagged — a release PR that bumps the version and updates `SKILL.md` but silently forgets `mcpHandoff.ts`. (5) Alignment sweep across the three bundled `SKILL.md` files: codex and gemini_cli both gained the "If that fails with `No such command 'install-skills'`" graceful-degradation note that was only in claude_code; claude_code's stale `/tmp/coarse-run.log` reference (predating the per-review unique log naming) is now `$LOG` with a cross-reference to the `--attach` pattern; the duplicate JSDoc block above `buildAgentPrompt` in `web/src/lib/mcpHandoff.ts` (a merge artifact that described `buildLaunchUrl` but was attached to the wrong function) is moved back above `buildLaunchUrl` where it belongs. Six new regression tests in `tests/test_cli_attach.py` pin the gaps the test-coverage review flagged: `test_attach_timeout_exits_124` (watcher gives up after `--attach-timeout` but leaves the pidfile so the user can re-attach to the still-running worker), `test_attach_log_never_appears_exits_3` (pidfile present, log file never written → exit 3 after the grace window), `test_handle_watcher_interrupt_with_live_pid_prints_reattach_hint`, `test_handle_watcher_interrupt_with_dead_pid_omits_reattach_hint`, `test_is_pid_alive_on_dead_pid_returns_false`, `test_is_pid_alive_on_live_pid_returns_true`, `test_is_pid_alive_on_zombie_returns_false`. The last one is the most important: it pins the `ps -o state=` zombie guard that prevents the watcher from treating a reaped-but-unwaited subprocess as alive — a test-environment concern that doesn't fire in production (where the detached worker is orphaned to init at launch time) but which would otherwise cause the whole test suite to wait out the `--attach-timeout` instead of exiting.

### Added

- **Preview-first deploy workflow documented and automated** — `dev` is now the default staging branch for deploy-affecting work. `.github/workflows/modal-preview-deploy.yml` deploys both Modal apps to the Modal `preview` environment from `dev` using protected GitHub Environment secrets, `.github/workflows/modal-deploy.yml` remains the production path from `main`, `web/src/app/api/mcp-extract/route.ts` now validates `MODAL_EXTRACT_URL` the same way `/api/submit` validates `MODAL_FUNCTION_URL`, and the operator docs (`deploy/PREVIEW_ENVIRONMENTS.md`, `deploy/DEPLOY.md`, `CLAUDE.md`, `AGENTS.md`) now point reviewers at the preview website for the latest `dev` commit before `dev` -> `main`.

- **`coarse-review --attach <log>` — signal-driven wait mode for coding-agent handoffs.** Replaces the per-agent-call polling loop in the handoff prompt with a single blocking watch command. `coarse-review --detach` now writes `<log>.pid` next to the log file containing the subprocess PID, and the detached child registers an atexit hook to unlink its own pidfile on orderly exit. The new `--attach <log>` flag reads that pidfile, opens the log from offset 0, streams new bytes to stdout as they appear, and polls `os.kill(pid, 0)` every 5 seconds until the worker exits. Every 30 seconds of log idleness it emits a `[attach] pid=<N> elapsed=<mm:ss> — waiting…` heartbeat so Claude Code's Bash tool sees stdout activity and doesn't flag the command as hung. Exit codes map to outcome: 0 (complete), 1 (failure marker — wins over success if both present), 2 (PID dead without a completion marker — probably silent crash), 3 (pidfile missing or malformed after the 5s grace window), 124 (exceeded `--attach-timeout`, default 1800s), 130 (Ctrl+C on the watcher — detached worker keeps running, with a re-attach hint). The watcher is passive: it never signals the detached process, so the agent can safely Ctrl+C out of `--attach` and re-invoke it against the same log. `_is_pid_alive` also probes `ps -o state=` and returns false on zombie processes — a test-only concern in production (the launcher orphans the worker to init at spawn time) but critical in pytest where the test process is the parent of the fake worker. Coding-agent prompts in `web/src/lib/mcpHandoff.ts::buildAgentPrompt` and the three bundled `SKILL.md` files (`_skills/{claude_code,codex,gemini_cli}/SKILL.md`) now instruct agents to use a two-call launch-and-wait pattern — `--detach` to start, `--attach` to block — with a ≥30-minute Bash-tool timeout discipline, eliminating the 10–25 per-review approval-prompt storm users were seeing with the old per-60s `tail -20` loop. Nine regression tests in `tests/test_cli_review.py` pin the contract (pidfile path, atomic 0600 write, grace-window behavior, happy-path stream + exit-0, failure marker, silent crash, heartbeat emission, mutual exclusion with `--detach`/`--handoff`/paper path, `_detach_review_process` pidfile write). `cli_review.py` is now allowlisted in `KNOWN_OVERSIZED` (`tests/test_architecture_drift.py`) with a TODO to split `--attach` into its own `cli_attach.py` in a follow-up refactor.

### Changed

- **Migrated transactional email from Gmail SMTP to Resend** — all three email-sending call sites now route through a verified `coarse.ink` Resend domain with `reviews@coarse.ink` as the sender. The web confirmation path (`web/src/app/api/submit/route.ts`) calls a new `sendReviewEmail` helper in `web/src/lib/email.ts` that wraps the `resend` npm client as a best-effort, never-throwing async function, replacing the inline `nodemailer` Gmail transport that killed production on 2026-04-13 when Gmail suspended the account and an uncaught `await mailer.sendMail(...)` crashed the route. The Modal worker (`deploy/modal_worker.py::_send_email`) now uses the `resend` Python client with the same no-op-on-missing-key contract, swallows every exception, and reads `RESEND_API_KEY` from a new `coarse-resend` Modal secret (the old `coarse-gmail` secret reference is gone from `@app.function(secrets=[...])`). The daily capacity monitor workflow (`.github/workflows/monitor.yml`) replaces the `dawidd6/action-send-mail@v3` Gmail step with a `curl` to `https://api.resend.com/emails` built via `jq` so multi-line alert bodies can't break the JSON envelope. The `EMAIL_DELIVERY_DISABLED` kill switch in `web/src/lib/emailCapacity.ts` is now `false` — Resend is live as the default path — but the flag stays as the one-line operational escape hatch for future outages. The frontend capacity gate bumps from 240 reviews/day (Gmail-calibrated) to 2500 reviews/day (Resend Pro-calibrated), and the monitor cron's warning threshold bumps from 200 to 5000. `.env.local.example`, `deploy/DEPLOY.md`, and `deploy/CAPACITY.md` all updated to document the Resend-based setup, key rotation across Vercel + Modal + GitHub secrets, and the new capacity math. `nodemailer` + `@types/nodemailer` removed from `web/package.json`; `resend>=2.0` added to `deploy/modal_worker.py` pip install. Five regression tests in `tests/test_modal_worker.py` cover: the happy path (resend client called with the exact `from`/`to`/`subject`/`html`), missing-key no-op (silent drop when `RESEND_API_KEY` is unset), exception swallowing (a raised error inside the Resend call never propagates to the caller), unknown-id tolerance (an empty `{}` response logs `id=unknown` instead of crashing), and secret scrubbing (a Resend error message containing a bearer token is run through `_sanitize_error` before reaching stdout).

### Fixed

- **Turnstile widget was CSP-blocked on coarse.ink for every production user (#111)** — the strict `Content-Security-Policy` header served from `web/next.config.ts` listed `googletagmanager.com` and `cdnjs.cloudflare.com` as allowed script sources but not `challenges.cloudflare.com`, and there was no `frame-src` directive at all so the widget iframe fell back to `default-src 'self'` and was silently refused. The net effect since #110 shipped was that every visitor hit "Please complete the human check before submitting" with no visible widget, no matter what browser they used — it was our own site blocking itself, not their ad-blocker. Added `https://challenges.cloudflare.com` to `script-src`, added a new `frame-src 'self' https://challenges.cloudflare.com` directive, and bundled a pre-existing sibling fix: `worker-src 'self' blob:` so pdf.js can actually spawn its client-side token-estimation worker (previously fell back through `script-src` and errored silently on strict browsers, breaking the cost preview). Verified locally with Cloudflare's always-pass test keys.
- **Turnstile widget also now degrades gracefully when a user's own browser extension blocks the script** — retained as defense-in-depth on top of the CSP fix. The landing page tracks a three-state `turnstileStatus` (`loading`, `ready`, `failed`) driven by the widget's `callback` / `error-callback` plus a 12-second watchdog that fires if the script renders but no token ever arrives (the telltale of Brave Shields, uBlock Origin with certain privacy lists, or Firefox ETP strict blocking `challenges.cloudflare.com`). On `failed` the empty widget slot is replaced with a clear explanation that names the likely extensions, tells the user to whitelist `coarse.ink` or switch browsers, and points at the CLI fallback (`uvx coarse-ink review paper.pdf`). The submit button stays disabled until the widget reaches `ready` (instead of being clickable against an empty token ref), and the "please complete the human check" client guard now distinguishes "still loading, try again in a moment" from the blocked-widget error so users aren't chasing a ghost widget. `resetTurnstile()` flips status back to `loading` during the ~1s Cloudflare takes to auto-refresh after each presign, keeping the submit button correctly disabled across retries. Fails open preserves the pre-existing dev-friendly behavior: when `TURNSTILE_SECRET_KEY` is unset, `turnstileStatus` stays `ready` and none of this path runs.

### Added

- **Modal worker now auto-deploys from `main` via CI with a local non-main deploy guardrail** — added `.github/workflows/modal-deploy.yml` which runs on every push to `main` that touches `src/coarse/**`, `deploy/modal_worker.py`, `pyproject.toml`, or the workflow file itself, installs `modal>=0.68 fastapi>=0.115 pydantic>=2.0`, and runs `modal deploy deploy/modal_worker.py` with `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET` from repo secrets. Runs serialize via a `modal-deploy` concurrency group so back-to-back pushes can't deploy in parallel, and the workflow is also `workflow_dispatch`-able for manual retries. The deploy step's "Validate Modal credentials" stanza fails fast with a clear error if the secrets haven't been added yet, so the first merge to main after this lands doesn't silently no-op. Belt to the CI suspenders: `deploy/modal_worker.py` now has an import-time guardrail (`_enforce_deploy_branch_at_import_time` + `_enforce_deploy_branch`) that refuses any `modal deploy` from a non-`main` branch with a RuntimeError pointing at the CI path and explaining the emergency escape hatch (`COARSE_MODAL_DEPLOY_FORCE=1`). The guard short-circuits inside pytest sessions (`sys.modules["pytest"]` or `PYTEST_CURRENT_TEST` set) so `tests/test_modal_worker.py` can still load the file via `importlib` on feature branches, and inside Modal containers at review-serving time (`modal.is_local()` is False) so cold-starts don't break. In CI it reads `GITHUB_REF_NAME` (which the deploy workflow only triggers on `main`); locally it shells out to `git rev-parse --abbrev-ref HEAD`. Git failures (detached HEAD, PyPI install with no repo) are silent no-ops rather than blocking a legitimate import. Seven new regression tests in `tests/test_modal_worker.py` cover CI main pass, CI non-main raise, local main pass, local non-main raise, local force-env warn, local git-error fall-through, and the import-time pytest short-circuit. `deploy/DEPLOY.md` step 3 now documents the auto-deploy path, the one-time `gh secret set MODAL_TOKEN_ID / MODAL_TOKEN_SECRET` setup, and the `COARSE_MODAL_DEPLOY_FORCE=1` emergency override. Incident context: on 2026-04-13 a `modal deploy` from a `dev` checkout shipped resurrected cheap-tier stage routing plus an api-key race to production, breaking every review for hours. This guardrail exists so that class of accident cannot happen again through the normal path.

### Security

- **Cloudflare Turnstile gate on the review submit flow** — `/api/presign` now verifies a Turnstile token via Cloudflare siteverify (5s timeout) before minting a review row or upload URL, so headless scripts can no longer drive the review pipeline. The widget is rendered above the landing-page submit button in managed mode, its token rides on the presign body, and the helper in `web/src/lib/turnstile.ts` fails open when `TURNSTILE_SECRET_KEY` is unset so local dev without a secret still works. Enforcement flips on automatically once `NEXT_PUBLIC_TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET_KEY` are set on Vercel. `/api/submit` is already tightly bound to presign via the existing signed upload URL, so protecting presign alone covers the whole flow.

### Removed

- **Re-reverted the per-stage cheap-tier model routing (`routing.py`, `StageRouter`, `STAGE_MODELS`, `CHEAP_STAGE_MODEL` = `z-ai/glm-5.1`, `CHEAP_STAGE_FALLBACK_MODEL` = `moonshotai/kimi-k2.5`, `CHEAP_STAGE_PROVIDERS`, `--stage-override`, `LLMClient(provider_allowlist=..., fallback_client=..., default_extra_body=...)`, `StageRouter` threading in `pipeline.py` and `structure.py::analyze_structure(..., math_client=)`).** The feature was originally reverted in `2949360` (2026-04-11) after end-to-end validation showed the savings were <1% of total review cost on a 34k-token paper on sonnet-4.6 (same ratio on opus and gpt-5.4-pro). The revert was silently undone by merge `06bab2b` (2026-04-13) because the long-lived `feat/mcp-server` branch was based on a commit *before* the revert and git's default delete-vs-modify resolution kept the resurrected files when the two sides met. The resurrected code was then actively breaking production: hosted Modal reviews were routing `metadata`/`math_detection`/`calibration`/`contribution_extraction` through `glm-5.1`+`kimi-k2.5` regardless of the user's selected model, and the `routing.py` layer was also the reason the OpenRouter api_key was getting silently dropped in the Modal container (cheap-stage clients built via `provider_allowlist` were hitting a slightly different code path than the main client). This commit re-applies the original revert surgically: `src/coarse/routing.py` and `tests/test_routing.py` are deleted, `src/coarse/models.py` loses `CHEAP_STAGE_MODEL`/`CHEAP_STAGE_FALLBACK_MODEL`/`CHEAP_STAGE_PROVIDERS`/`STAGE_MODELS` and the `z-ai` entry in `JSON_MODE_PREFIXES`, `src/coarse/llm.py` goes back to a plain `LLMClient(model, config)` constructor (no `provider_allowlist`/`fallback_client`/`default_extra_body`, no `_apply_provider_prefs`, no `_complete_primary`/`_complete_text_primary` wrappers, no model-level fallback dispatch), `src/coarse/cost.py` drops `stage_overrides` + `_stage_model` and routes every stage through the single `model`, `src/coarse/pipeline.py` uses one `LLMClient` for every agent and `analyze_structure` goes back to a single-client signature, `src/coarse/headless_review.py` stops monkey-patching `StageRouter`, and `deploy/modal_worker.py` removes the `COARSE_DISABLE_STAGE_ROUTING` kill switch that was added as a band-aid for the resurrection. The `b271b99` api_key fix is preserved: `LLMClient.__init__` still resolves the OpenRouter key eagerly via `resolve_api_key(self._model, config)`, stashes it on `self._api_key`, and forwards it through `call_kwargs["api_key"]` on every `complete()` / `complete_text()` call so litellm never has to read `os.environ` mid-flight.
- **Bring-your-own-subscription handoffs now launch reliably across coding agents and map effort levels directly** — `coarse-review` now has a built-in detached launcher (`--detach --log-file ...`) instead of relying on shell-level `nohup` patterns that can be reaped by agent tool runners, and it creates detached log files with owner-only permissions. It also preloads `OPENROUTER_API_KEY` from the same env/config/.env lookup path used by `headless_review`, and `headless_review` now recognizes the `api_keys.openrouter` config format written by `coarse setup`, so the safer user-scoped config path works in the handoff flow. The web handoff prompt, landing page, and all bundled Claude Code / Codex / Gemini CLI skills now force Python 3.12 via `uvx --python 3.12`, prefer saving OCR keys via `coarse setup` instead of a repo-local `.env`, document that the review runs locally under the user's own provider account, and make clear that coarse.ink does not proxy provider logins. Codex effort mapping is now direct and defensible: `low -> low`, `medium -> medium`, `high -> high`, `max -> high`, removing the earlier downshift that mapped coarse `high` to Codex `medium` and could map `low` to unsupported `minimal`; Gemini effort is no longer a no-op and now injects explicit advisory reasoning guidance per effort tier. Added regression coverage for detached relaunch, secure log creation, config-based OCR key discovery, direct effort mapping, and instruction drift across the shipped skill/web surfaces.
- **Bring-your-own-subscription handoffs now use single-use browser proof-of-possession and fenced MCP review content** — `/api/presign` now mints a high-entropy per-review handoff secret that `/api/submit`, `/api/cli-handoff`, `/api/mcp-extract`, and `/api/mcp-handoff` must present before they can mint worker or handoff capabilities, and the secret is deleted on successful submit/finalize so a leaked review UUID is no longer enough to keep minting new work. The remote MCP path also now wraps abstract/section text in explicit `<paper_*>` tags and prepends the same boundary notice used by the main prompt stack so paper content cannot silently override host instructions.
- **Kimi/OpenRouter structured output now uses native JSON mode with healing and a fallback path** — `openrouter/moonshotai/kimi-k2.5` no longer defaults to markdown-fenced JSON for structured calls. `LLMClient` now prefers Instructor JSON mode for Kimi on OpenRouter, adds `provider.require_parameters=true` plus the `response-healing` plugin to improve malformed-JSON recovery, and falls back to `MD_JSON` only when the route rejects structured-output parameters. Separately, malformed `OverviewFeedback` payloads that still contain complete emitted issues are salvaged instead of failing the whole review. Added regression coverage for mode selection, OpenRouter parameter injection, route-rejection fallback, and truncated-overview recovery, plus live OpenRouter probes validating the new path against Kimi.
- **Hosted reviews now explain wrong API-key type failures more clearly** — `coarse.ink` always expects an OpenRouter key, even when the selected model is labeled `openai/...` or `anthropic/...`. The Modal worker now detects obvious direct-provider key prefixes (`sk-`, `sk-ant-`, `gsk_`, `pplx-`, `AIza...`) before the pipeline starts and returns a targeted message telling users to resubmit with an OpenRouter key instead of the old generic “Invalid API key” 401. Added regression coverage for accepted OpenRouter keys, rejected direct-provider keys, and unknown prefixes.
- **CLI cost confirmation is no longer obscured by the live status spinner, and `coarse --version` now works** — the Rich `Status("Running review pipeline...")` wrapper in `src/coarse/cli.py` used to cover the entire `review_paper()` call, but the interactive cost gate lives inside `review_paper()` itself. On default interactive runs, the terminal could therefore display the live spinner as if the review had already started while `typer.confirm(...)` was still waiting for stdin, which looked like the confirmation prompt had been ignored. The CLI now keeps the spinner only for the non-interactive `--yes` path, leaving the prompt unobscured on normal runs, and adds a real eager top-level `--version` option wired to `coarse.__version__`. Regression tests in `tests/test_cli.py` pin both the spinner gating and the new version flag behavior.
- **Quote verification now corrects quotes before downstream review handoffs without dropping recall** — the pipeline now programmatically verifies or repairs quotes between section/proof/cross-section/editorial stages so later agents reason over grounded text, but intermediate handoffs use non-dropping verification (`[approximate]` fallback) to preserve review coverage when extraction is noisy. The final output gate remains strict. This review-driven cleanup also removes the dead assumption-checker path and normalizes the recently changed module test files to the `tests/test_{module}.py` convention.
- **The web app now keeps OpenRouter OAuth keys in tab-only storage and validates Modal webhooks before sending secrets (#95)** — the browser now reads any legacy `localStorage` key once, migrates it into `sessionStorage`, and deletes the persistent copy so a reusable bearer credential is no longer left in local storage. The submit route also rejects invalid `MODAL_FUNCTION_URL` / empty `MODAL_WEBHOOK_SECRET` configurations up front, requires `https` for real deployments, and adds a timeout to the secret-bearing Modal trigger call.
- **Extraction now logs real OpenRouter denial details and falls back after paid OCR denials (#71)** — the PDF extractor no longer hard-fails immediately when the first `mistral-ocr` file-parser call comes back as a user-actionable OpenRouter denial. It now records a scrubbed one-line summary of the provider response body (status, message, code, metadata when present) so operators can distinguish `402 Payment Required` / spend-limit failures from `403 Forbidden` privacy-or-terms failures in Modal logs, and it allows the extraction chain to continue from paid `mistral-ocr` to free `pdf-text` / `cloudflare-ai` and then Docling for recoverable OpenRouter `402` / `403` denials. `401` auth failures still fail fast. New tests pin both production-shaped cases: `402` and `403` on the first extraction call now skip retries on that backend, log the scrubbed reason, and successfully fall through to the next extractor when available.
- **Prompt builders now fence and sanitize every remaining raw paper-content path (#92)** — the domain calibration and math-detection prompts now include the same boundary notice used by the review agents, and the previously inconsistent `calibration_user`, `completeness_user`, `cross_section_user`, and `math_detection_user` helpers now run untrusted paper-derived fields through `_strip_fence_tags()` before placing them in fenced blocks. This closes the remaining easy `</paper_content>` / `</paper_sections>` escape paths for extracted PDFs and adds regression coverage for injected closing tags and inline instruction payloads.
- **The default install no longer resolves vulnerable `diskcache`, and the optional Docling stack now resolves safe `transformers` releases (#98)** — `instructor` is now pinned to a version where `diskcache` moved behind an optional extra instead of the default dependency set, and the `docling` extra now requires a current Docling release plus `transformers>=5.4,<6` so the optional extraction fallback no longer locks to the older vulnerable 4.x line.

### Added

- **Architecture drift tests now enforce package boundaries** — new regression tests statically scan `src/coarse/` to block import cycles, keep `models.py` dependency-free, keep `types.py` / `prompts.py` within their allowed layers, and prevent new non-whitelisted imports of `pipeline.py`.

### Changed

- **Pipeline cost heuristics now come from a shared stage manifest** — the review section cap, stage output budgets, math/cross-section heuristics, and web cost-estimator constants now live in `src/coarse/pipeline_spec.py`, with a generated JSON export consumed by the frontend. This removes the hand-maintained drift between `pipeline.py`, `cost.py`, and `web/src/lib/estimateCost.ts`.

### Changed

- **Architecture docs now match the runtime pipeline** — README, CONTRIBUTING, and CLAUDE now describe the single overview pass, completeness stage, cross-section/editorial flow, and legacy `crossref`/`critique` fallback that the code actually runs. The changelog entry that prematurely claimed `StageRouter`/`STAGE_MODELS` shipped was also corrected, and `scripts/doc-sync-check.sh` now blocks stale architecture phrases from creeping back in.

### Changed

- **`pipeline.py` is back to being an orchestrator** — the section-review helpers, calibration/contribution extraction, and editorial fallback logic moved into `src/coarse/review_stages.py`, leaving `pipeline.py` responsible for stage ordering, fan-out, and final assembly instead of stage-local implementation details.

### Changed

- **Extraction internals are split by responsibility without changing the public API** — cache I/O moved to `extraction_cache.py`, format backends to `extraction_formats.py`, and OpenRouter transport/error handling to `extraction_openrouter.py`, while `extraction.py` now serves as the stable facade for `extract_text()` and `extract_file()`.

### Fixed

- **Web UI disabled the email field and showed a banner while the coarse Gmail account was suspended** — added an `EMAIL_DELIVERY_DISABLED` kill switch in `web/src/lib/emailCapacity.ts` that forced `isEmailCapacityReached()` to return true and made `/api/status` return `emailCapacityReached: true` plus a top-banner message, so the landing page disabled the email input and accepted empty-email submissions during the outage. This was the immediate stopgap for the 2026-04-13 Gmail suspension; it is now obsolete because the Resend migration above replaces the Gmail SMTP path entirely. The flag remains as a one-line operational escape hatch but is now `false` by default.

## v1.2.2 — 2026-04-12

Patch release. Clears the false "system busy (N/20 slots in use)" banner that the landing page was showing even when Modal was idle, and bundles the unreleased `dev` work (GPT-5.4 compare-panel + author-steering fallthroughs) that had accumulated since v1.2.1.

### Fixed

- **Landing-page "system busy" banner no longer fires on abandoned presign rows** — `/api/presign` inserts a `reviews` row with `status='queued'` before the file is even uploaded, so any user who closes the tab or bails at the API-key prompt leaks a phantom `queued` row that no code path ever transitions. `/api/status` was counting all `queued`/`running` rows regardless of age, so the banner (which fires at 80% of the 20-slot capacity) tripped at 16 abandoned presigns even when Modal was idle. `/api/status` now only counts rows created within the last 2.5h (Modal's 2h review timeout plus a small cushion), and a new `.github/workflows/sweep_stale_reviews.yml` runs every 15 minutes to flip any `queued`/`running` row older than 3h to `status='failed'` with an "abandoned or worker crashed" message, so the `/review/<id>` page stays honest for users who actually did submit.

### Added

- **Compare page now includes GPT-5.4 benchmark results** — added `gpt54` as a first-class model on the side-by-side comparison page, loaded the April 12 GPT-5.4 review and Gemini judge files for all four benchmark papers, and exposed the new panel in both the selector and the score overview table. The overview table now derives its values from the checked-in quality reports rather than a duplicated hardcoded matrix, and the compare-data loader normalizes JSON-style `\uXXXX` escapes in review markdown so generated benchmark artifacts render correctly on the site.

### Fixed

- **Author steering notes now reach all downstream review passes** — `author_notes` is now forwarded beyond the original overview/section/editorial path into `completeness`, `proof_verify`, `cross_section`, and the legacy `crossref`/`critique` fallback so later passes cannot silently drop the requested focus. The shared `author_notes_block()` prompt wrapper was also strengthened to tell agents how to prioritize valid note-aligned comments, while still preserving the rubric, quote rules, and prompt-injection boundary.

### Fixed

- **Author steering notes now reach every output-shaping review pass** — `author_notes` is now forwarded beyond the original overview/section/editorial path into `completeness`, `proof_verify`, `cross_section`, and the legacy `crossref`/`critique` fallback so later review passes cannot silently ignore the user's requested focus. The shared `author_notes_block()` prompt wrapper was also strengthened to treat notes as non-binding prioritization, explicitly handle placeholder/draft sections, and clarify that notes cannot override rubric or quote requirements. Added unit and pipeline coverage for the new forwarding paths and prompt semantics.

### Fixed

- **Author steering now reaches all output-shaping review passes** — `author_notes` is now forwarded beyond the original overview/section/editorial path into `CompletenessAgent`, `ProofVerifyAgent`, `CrossSectionAgent`, and the legacy `CrossrefAgent` / `CritiqueAgent` fallback, so later review passes cannot silently drop the user's requested focus. The shared `author_notes_block()` guidance is also stronger: it now treats notes as non-binding prioritization, explicitly handles placeholder/draft sections, and clarifies that notes can steer attention without overriding the review rubric or suppressing concrete issues. Tests now cover the expanded forwarding path plus the fallback route.

### Fixed

- **Author steering notes now reach every output-shaping review pass** — the follow-up to the original notes feature now forwards `author_notes` beyond just overview/section/editorial into `completeness`, `proof_verify`, `cross_section`, and the legacy `crossref`/`critique` fallback path, so later stages no longer drift away from the requested focus. The shared `author_notes_block()` wrapper was also strengthened: it still preserves the rubric and quote rules, but now explicitly tells agents to use the notes as non-binding prioritization, to prefer note-relevant comments when issues are otherwise similarly important, and to avoid spending comments on placeholder sections merely for being incomplete unless that incompleteness affects the paper's core claims or publishability. New tests pin the forwarding and prompt behavior across all affected agents and the pipeline fallback path.

## v1.2.1 — 2026-04-11

Patch release. Single fix: the author-notes textarea added in v1.2.0 (#67) inherited a placeholder color (`var(--tray)` ≈ `#2A3138`) that is nearly identical to the textarea's own background color (`var(--board-surface)` ≈ `#242D33`), making the multi-line placeholder hint illegible on the submit page. Also adds an end-to-end validation of the v1.2.0 author-notes feature against a synthetic paper — see issue #54 for details.

### Fixed

- **Legible placeholder color for the author-notes textarea (#69)** — new `.field-line-textarea` class in `web/src/app/globals.css` with `::placeholder { color: var(--dust); opacity: 1; font-style: italic }`. `var(--dust)` (`#6B6862`) has readable contrast against `var(--board-surface)`, unlike `var(--tray)` which the `.field-line` class was using. Also collapses the 14 lines of duplicated inline styles on the `<textarea>` in `web/src/app/page.tsx` into the new class, matching the existing `field-line` / `field-line-mono` pattern. Only cosmetic — no behavior change, no test churn.

## v1.2.0 — 2026-04-11

Minor release. Headline is the new **optional author-steering notes** field on the submit page (#54): authors can attach a short instruction ("please focus on the identification strategy in §3") and the reviewer adapts its focus. Also ships the **document-form detection** feature so the reviewer stops demanding full-manuscript rigor on outlines and proposals. Hardens the Modal worker in two places: the sanitizer no longer collapses `instructor.InstructorRetryException` messages to a bare `</last_exception>` tag (#55), and extraction + worker now scrub NUL bytes before Supabase writes (#62). Web-side adds the auto-disabling email-capacity gate (#59) and bumps the stale monitoring-cron threshold (#66). Prompt caching on OpenRouter-routed Claude/Gemini is re-enabled and the `proof_verify` stage is gated on a 500-char / non-empty-claims threshold for a modest cost win.

### Added

- **Optional author-steering notes on the submit page (#54)** — the submit form now has an optional `Notes for the reviewer` textarea (max 2000 chars) that the author can use to steer the review: "please focus on the identification strategy in §3 — the data section is still a placeholder." The note is plumbed end-to-end: frontend → `/api/submit` (server-side length validation) → Modal webhook → `ReviewRequest.author_notes: str | None = None` (defaulting to None so older in-flight spawn payloads still deserialize) → `review_paper(..., author_notes=author_notes)` → the **overview, section, and editorial agents** via a new `author_notes_block(notes)` helper in `prompts.py`. The helper returns `""` for None/empty/whitespace-only input so the no-notes path is byte-identical to the pre-feature behavior and prompt caching on the cached system blocks is completely unaffected. Non-empty input is truncated to 2000 chars, run through `_strip_fence_tags` (which gains `<author_notes>` as a recognized fence), wrapped in an `<author_notes>` fence, and prepended to each agent's **user** message (never the system message) with a short instruction telling the agent to treat the notes as a request for focus, not as commands that override the review rubric. The content-boundary notice in `prompts.py` also gains `<author_notes>` so every agent that imports the notice is explicitly told the block is boundary-fenced data. Security: three defenses in depth — (1) server-side 2000-char cap in `web/src/app/api/submit/route.ts`, (2) `_strip_fence_tags` removal of any injected `<paper_content>` / `<author_notes>` / etc. closing tags so an attacker can't close the fence early, (3) the wrapper text instructs the agent that the content is steering, not instructions. 15 new tests: `test_prompts.py` covers the `author_notes_block` helper (empty/None/whitespace byte-identity, non-empty wrapping, fence-tag injection defense, truncation at 2000 chars, required steering frame instruction, `_FENCE_TAG_RE` matches `<author_notes>`, content-boundary notice lists the tag); `test_overview-agent.py`, `test_section-agent.py`, `test_editorial.py` each cover byte-identity of the user message when notes are None/omitted/whitespace-only, plus the prepend-to-user-message path when notes are supplied, plus that the notes text never leaks into the cached system block; `test_pipeline.py::test_review_paper_forwards_author_notes_to_all_review_agents` is the glue test that captures the forwarded value at every downstream agent; `test_modal_worker.py` pins that `ReviewRequest` deserializes `author_notes` from the webhook payload and defaults to None. Deliberately NOT routed through: `completeness`, `cross_section`, `proof_verify`, `literature`, `calibration`, `contribution_extraction` — these do structural / procedural work that shouldn't be steered by free-form author input. If that turns out to be wrong, extending is a one-line change per agent.
- **Document-form detection + prompt branching** — the review pipeline now distinguishes completed manuscripts from earlier drafting stages (outline, partial draft, proposal, technical report, working notes, other) and adapts the agent prompts accordingly. The cheap metadata LLM call in `src/coarse/structure.py::_get_metadata` gains a `document_form` field on its `PaperMetadata` response model; the classifier has a rubric in `METADATA_SYSTEM` explaining each form (heading vs. prose density, presence of results, conditional phrasing like "if supported by data", pre-committed discussion bullets). `PaperStructure` carries the classified form through to downstream agents. A new `document_form_notice(form)` helper in `prompts.py` returns a tailored system-prompt addendum for each non-manuscript form — and the empty string for `manuscript`, so the default peer-review path is byte-identical to the pre-feature behavior (all agent tests pinning `system_content == OVERVIEW_SYSTEM`/`COMPLETENESS_SYSTEM`/`SECTION_SYSTEM`/`EDITORIAL_SYSTEM`/`PROOF_VERIFY_SYSTEM`/`CROSS_SECTION_SYSTEM` still pass). Overview, section, editorial, proof-verify, and cross-section agents all append the notice; the completeness agent additionally short-circuits to `[]` for outline/notes/other since "flag missing content" is noise by construction when ~100% of the content is missing by design. Pipeline threads `structure.document_form` through `_review_section`, `verify_agent.run`, `cross_section_agent.run`, and `editorial_agent.run`. Two safety defenses land alongside the classifier: (1) a prose-heavy heuristic in `analyze_structure` that overrides an LLM misclassification from outline/notes/other back to `draft` when the document actually has substantial non-bullet prose — this defends against the manuscript→outline direction, which silently degrades real peer reviews into 2-3 soft comments; (2) the metadata-extraction fallback on LLM failure now returns `document_form="draft"` instead of `"manuscript"`, because defaulting to the punitive strict-review frame on a classifier failure recreates the exact bug this feature fixes. Motivated by an April 11 review (`bfa57ebb-4ab6-4937-a724-9757650f7ea0`, Claude Opus 4.6) of a paper outline where 5 out of 8 overall-feedback items were variations of "this isn't a manuscript, you haven't written any prose" with a final `Recommendation: reject` — actively unhelpful to an author in the planning stage. 50 new tests across `test_types.py`, `test_prompts.py`, `test_structure-analysis.py`, `test_overview-agent.py`, `test_completeness-agent.py`, `test_section-agent.py`, `test_editorial.py`, `test_verify.py`, and `test_cross_section-agent.py` pin the manuscript path (byte-identical on every agent), the branching-on-form behavior, the completeness early-return, focus-specific section prompt routing (parametrized over general/proof/methodology/discussion/literature), the `analyze_structure` form-propagation glue test, the heuristic override and its non-firing cases, the draft fallback on metadata failure, and drift protection via `typing.get_args(DocumentForm)` to guarantee every literal value has a notice entry. `preprint` was dropped from the `DocumentForm` literal per YAGNI — it had the empty-string notice (same as `manuscript`) and existed only to satisfy a rubric entry; preprints are classified as `manuscript` and get the full peer-review frame.
- **MCP server for subscription-routed reviews** — new `deploy/mcp_server.py` FastMCP app exposes eight tools (`upload_paper_url`, `upload_paper_bytes`, `upload_paper_path`, `load_paper_state`, `get_paper_section`, `get_review_prompt`, `verify_quotes`, `finalize_review`) so Claude.ai custom connectors, Claude Code, Gemini CLI, and the ChatGPT Apps SDK can drive a paper review using the host's own LLM subscription. The host model runs the review reasoning (overview → per-section → crossref → critique); this server only handles prompt rendering, quote verification, and synthesis. **Capability-based architecture: the MCP server holds no Supabase service key, no Modal secrets, and no persistent auth.** The web→MCP round-trip flow: user clicks "Review with my subscription" → POST `/api/mcp-extract` spawns a new Modal `do_extract` function that runs Mistral OCR + structure parsing server-side and writes the state blob to `papers/<uuid>.mcp.json` (status flips to `'extracted'` for a Supabase realtime subscription to pick up) → POST `/api/mcp-handoff` mints a 15-minute signed URL for that blob plus a 60-minute single-use `finalize_token` → browser copies the handoff prompt (no OpenRouter key anywhere) and opens the chat host → host calls `load_paper_state(signed_state_url, paper_id)` to rehydrate in <1s → walks the review prompts → `finalize_review` with the token POSTs the rendered markdown to `/api/mcp-finalize`, which validates+consumes the token and upserts the reviews row via its own service key. Worst case of a compromised tunnel or clipboard: download one state blob for 15 minutes and write one review for 60 minutes. No ambient authority. New `deploy/migrate_mcp_handoff.sql` adds the `mcp_handoff_tokens` table and expands `reviews.status` to include `extracting` + `extracted`. Supporting changes: `extract_and_structure()` helper factored out of `pipeline.py`, `get_prompt(stage, **ctx)` accessor added to `prompts.py`, new optional `[mcp]` extra pinned to `fastmcp>=2.10`, new `coarse mcp-ingest <file> [--server URL]` CLI subcommand. New `deploy/mcp_test_client.py` is an end-to-end harness that drives the full pipeline against a running server (uses one OpenRouter key for both extraction and simulated review reasoning, good for CI). Web frontend gains a "Review with my subscription ▾" button next to the existing "Review my paper" submit, with a provider dropdown (Claude.ai, ChatGPT, Claude Code, Gemini CLI). Click → extraction spinner → clipboard copy + open host tab. New `/mcp` landing page walks users through the one-time per-host connector install. Install docs in `docs/mcp-install.md`; deploy instructions + security model in `deploy/DEPLOY_MCP.md`. 43 new tests in `tests/test_mcp_server.py` cover every tool path. The MCP Modal function (`coarse-mcp` app) + the extract worker deploy to a separate Modal app from `coarse-review` — zero risk to the existing OpenRouter production path.

### Fixed

- **Remote-MCP dev flow is now a first-class, validated launcher** — added `scripts/run_remote_mcp_dev.py` for the production-like topology where the Next.js web app runs locally behind a public tunnel while the MCP server runs remotely. The helper normalizes and validates `--site-url` / `--mcp-url`, rejects localhost/private callback hosts a remote worker cannot reach, probes the remote MCP endpoint before booting `npm run dev`, sets `NEXT_PUBLIC_SITE_URL` + `NEXT_PUBLIC_MCP_SERVER_URL`, and fails fast if the local dev server exits before the tunnel becomes reachable. `deploy/DEPLOY_MCP.md` and `docs/mcp-install.md` now document this workflow explicitly, backed by `tests/test_remote_mcp_dev.py`.
- **Headless coding-agent review flow now defaults to ephemeral `uvx` execution** — the bundled Claude Code / Codex / Gemini CLI skills and the web handoff prompt/landing page no longer tell users to persistently reinstall `coarse-ink` before every run. Instead they refresh the shipped skill files with `uvx --from 'coarse-ink[mcp]==1.2.2' coarse install-skills --all --force` and launch reviews with `uvx --from ... coarse-review ...` under `nohup`, which is safer for long-running agent sessions and avoids mutating the user's global tool environment. The homepage clipboard prompt now reuses the canonical `buildAgentPrompt()` text so Codex deep links, Claude/Gemini paste prompts, and the browser landing page stay in sync.
- **Cost tracker silently zeroed on OpenRouter aliased models** — `LLMClient.complete()` / `complete_text()` called `litellm.completion_cost(completion_response=completion)` without an explicit `model=` override, so cost lookup fell through to the completion response's own `model` field. OpenRouter rewrites aliases like `qwen/qwen3.5-plus-02-15` to concrete date-stamped versions (`qwen/qwen3.5-plus-20260216`) which aren't in `_CUSTOM_MODEL_INFO`, and `completion_cost()` raised `This model isn't mapped yet`; the caller swallowed the exception at DEBUG level so the counter stayed at `0.0` for every Qwen review the user actually ran. Fix passes `model=self._model` explicitly at both call sites so lookup hits the registered alias. Discovered while driving the new MCP test harness — `LLMClient.cost_usd` read as `$0.0000` at the end of a full review despite real token spend. Orthogonal to PR #47's estimator drift: that PR fixed the pre-flight *estimate*; this fix restores the post-call *actual*.


### Changed

- **Monitoring cron daily-reviews warning threshold bumped 40 → 200** — `.github/workflows/monitor.yml` was set to warn the maintainer whenever daily review volume crossed 40, a number from the pre-launch era (<1 review/day). 40 reviews = 80 emails, nowhere near Gmail's 500/day sender cap, so the warning was pure noise. 200 reviews ≈ 400 emails ≈ 100-email buffer below the cap, which lines up with the frontend email-capacity gate at 240 in `web/src/lib/emailCapacity.ts` (the cron now warns first, the frontend auto-disables the input second). Also updated the matching threshold table in `deploy/CAPACITY.md`. Monthly thresholds (300 / 800) deliberately unchanged — they track Modal compute, not email volume, and need a separate data-driven revisit. (#66)
- **`proof_verify` gated on a 500-char / non-empty-claims threshold** — `pipeline.py::_review_section` now skips the adversarial proof-verification pass on sections flagged `math_content=True` that are both short AND have no extracted formal claims. The gate is in a new `_section_needs_proof_verify(section)` helper and requires either (a) `section.claims` non-empty — any extracted Theorem/Lemma/Proposition header bypasses the length check so short lemma-with-proof paragraphs still verify — or (b) `len(section.text) >= 500` chars. Threshold is deliberately low so short lemmas with their proofs aren't missed (typical short-proof paragraphs run 300-800 chars). Skips pure-wasted calls on sections where `math_content` was flagged from a single inline equation in a footnote, saving one proof_verify call (~$0.30 on sonnet-4.6, more on reasoning models) per such section. Expected savings: 0-10% per review, variable by paper — math-heavy papers with long proofs see no change. Five new tests in `test_pipeline.py` pin the math_content/claims/length matrix + the `_review_section` integration.

### Fixed

- **Instructor retry errors no longer collapse to a bare `</last_exception>` tag** — `deploy/modal_worker.py::_sanitize_error` used a "last non-empty line" heuristic to trim Python tracebacks before writing `error_message` to Supabase. For `instructor.exceptions.InstructorRetryException`, whose `str(exc)` is an XML-like envelope (`<last_exception><failed_attempts><exception>…`), that heuristic kept only the outermost closing tag and threw away the actually-useful payload — so every instructor-retry failure showed up in the operator dashboard as the single string `</last_exception>`. The fix adds a regex pre-pass that extracts the first `<exception>…</exception>` block when present, falls back to the last-line heuristic otherwise, and as a safety net detects the case where the "last line" is itself a bare closing tag (and falls back to the whole trimmed message rather than keep garbage). Secret scrubbing still runs on the extracted text. Five new tests in `tests/test_modal_worker.py` pin: instructor envelope extraction, closing-tag-only fallback, single-line passthrough, classic multi-line traceback (still takes the final `ValueError: …` line), and that a key pasted inside a retry envelope is still scrubbed. Also one-time operator backfill: the 7 existing `</last_exception>` rows in the Supabase `reviews` table were updated with a note pointing at this issue. (#55)
- **NUL byte (`\\x00`) scrubbing before Supabase writes** — two pipeline failures on 2026-04-11 hit Postgres SQLSTATE `22P05` (`\u0000 cannot be converted to text`) after the user had already paid for the full review. `text` columns reject the real NUL byte and PostgREST's JSON decoder rejects the 6-char `\u0000` escape; some OCR backends and some reasoning-model outputs emit both. A partial defense in `src/coarse/llm.py::_sanitized_completion` already stripped these from LLM response content, but the extraction path wasn't covered and the modal worker was writing raw `paper_markdown` / `result_markdown` / `error_message` straight to Supabase. Added a shared `_strip_nul_bytes(text)` helper in `src/coarse/extraction.py` (idempotent, safe on empty/None) applied at the `full_markdown` seam in both `extract_text` and `extract_file`, plus a sibling `_strip_nul_bytes` in `deploy/modal_worker.py` wrapping every Supabase text-field update (`paper_markdown`, `result_markdown`, `error_message`). Seven new tests across `tests/test_extraction.py` and `tests/test_modal_worker.py` cover: real NUL removal, literal `\u0000` escape removal, None/empty-string safety, normal text unchanged, and end-to-end extract_text scrubs NULs emitted by a mocked OCR backend. (#62)
- **Email-capacity gate on the submit page** — when the rolling 24-hour review count crosses 240 (≈480 emails, leaving a small buffer below Gmail's free-tier 500/day sender cap), the submit form disables the email input and shows a short red note: "Daily email cap hit — save your review key to retrieve it." Submission is still allowed without an email; the Modal worker already guards `if email:` before sending the completion message, so review processing is unaffected. The threshold lives in `web/src/lib/emailCapacity.ts` (`EMAIL_CAPACITY_REVIEW_THRESHOLD`), `/api/status` returns the new `emailCapacityReached` flag alongside `activeReviews`, and `/api/submit` re-checks server-side so a client can't bypass the email regex by lying about capacity (skips the `review_emails` not-null and regex validation only when the DB confirms the gate is active, and suppresses the confirmation email when the address is empty). Motivated by the 2026-04-10/-11 traffic spike where daily volume jumped from <10 to 96-142 reviews — a single 2x growth day would have quietly started bouncing completion emails. (#59)
- **Prompt caching re-enabled for OpenRouter-routed Claude and Gemini** — `LLMClient.supports_prompt_caching` had a stale openrouter/ exclusion that silently disabled Anthropic prompt caching for every production review (coarse users upload OpenRouter keys only, never direct Anthropic). OpenRouter has forwarded explicit per-block `cache_control` breakpoints to Anthropic since mid-2024 — the gate was months out of date. Flipping it on activates two already-written caching paths: `agents/base.py::_build_messages` caches the system prompt on every agent (section agents share one system prompt across N parallel calls, so 1 write + N-1 reads fires immediately on every multi-section review), and `agents/overview.py` caches the full paper-context prefix in the system block. A follow-up commit extends the match to also cover Google Gemini models — per OpenRouter's current docs, "Gemini caching in OpenRouter requires you to insert cache_control breakpoints explicitly within message content." The match covers `anthropic/claude-*`, `openrouter/anthropic/claude-*`, `vertex_ai/claude-*`, `gemini/gemini-*`, `google/gemini-*`, `openrouter/google/gemini-*`, and `vertex_ai/gemini-*` forms; the extraction_qa vision stage (default model `gemini/gemini-3-flash-preview`) gets caching too. OpenAI and DeepSeek are deliberately NOT matched — both auto-cache prompt prefixes server-side and don't need `cache_control` blocks; adding them would clutter requests with no-op metadata. Cost math: cache writes billed at 1.25× input, reads at 0.1× (90% off), break-even at 0.28 reads per write. Every `cache_control` block in coarse's pipeline clears that bar trivially. Expected savings: ~$0.09-0.20 per sonnet review, ~$0.14-0.30 per opus review, scaling with section count and paper length. 8 new tests in `test_llm.py` cover direct anthropic, OR-routed anthropic, direct gemini, OR-routed gemini, vertex_ai/claude, vertex_ai/gemini, plus negative cases for OpenAI / DeepSeek (auto-cache, gate stays False) and undocumented providers (Qwen, Mistral, Kimi, glm, Grok).

## v1.1.6 — 2026-04-11

Security release. Headline fix is the user-OpenRouter-key ferry (#50): the user's API key no longer rides through Modal's `spawn()` managed-queue payload. The three routing entries below were documented in error during the v1.1.4/v1.1.5 cycle and did not ship in v1.1.6; the runtime still used a single shared `LLMClient` threaded through `pipeline.py`. They are retained here as historical notes so the release log stays auditable. The release also includes a cheap-tier glm-5.1 thinking-mode fix found in end-to-end testing and a double-click race fix in the `/api/submit` route.

### Security

- fix(deploy): ferry user OpenRouter key via `review_secrets` side-table so it no longer rides through Modal's `spawn()` payload / managed queue. Frontend inserts the key under RLS deny-all, worker reads + deletes in one shot via `_fetch_and_consume_user_key`, hourly GitHub Actions cron sweeps stragglers older than 3h. Worker keeps the `req.user_api_key` fallback for backward-compat with in-flight jobs during rollout. Requires running `deploy/migrate_review_secrets.sql` in Supabase SQL editor before deploying the worker. (#50)
- fix(web): make the `/api/submit` route double-click-safe. Previously, a double-click fired two POSTs with the same presigned `id`; the second one's `review_emails` insert hit a Postgres unique_violation and the rollback handler `delete from reviews` nuked the first submit's row, leaving submit 1's Modal worker running against a deleted review. Now the route detects SQLSTATE 23505 on the `review_emails` insert, treats it as an idempotent success, and returns 200 without firing a second Modal worker. Mid-submit rollbacks switched from `.delete()` to `.update({ status: "failed" })` so a concurrent double-click never sees a disappearing row. (#50)

### Added

- **Historical note: per-stage model routing docs were premature** — this feature set (`--stage-override`, `STAGE_MODELS`, `StageRouter`) was described in release notes before the implementation existed. It did not ship in v1.1.6; the actual runtime still used one shared `LLMClient` in `pipeline.py`. Any future implementation should land in code first, then be documented.
- **Historical note: cheap-tier provider allowlist docs were premature** — the documented `CHEAP_STAGE_PROVIDERS` allowlist and related provider-routing helpers were not present in the released code for v1.1.6.
- **Historical note: cheap-tier kimi fallback docs were premature** — the documented `CHEAP_STAGE_FALLBACK_MODEL` / router-level fallback path was not present in the released code for v1.1.6.

### Fixed

- **Historical note: cheap-tier glm-5.1 routing fix docs were premature** — this note referred to the same unshipped stage-routing implementation above and did not correspond to code in the v1.1.6 release artifact.

## v1.1.5 — 2026-04-11

Release-automation and web parity follow-up to v1.1.4. Ships the PyPI auto-publish workflow so future releases land on PyPI on tag push without a manual `uv publish` step, and brings the client-side TypeScript cost estimator up to parity with the Python one (same stage list, same budgets, same 1.30x buffer, same reasoning-model overhead). v1.1.4 was never published to PyPI — v1.1.5 is strictly a superset of its code, so `pip install coarse-ink==1.1.5` gets you everything in v1.1.4 plus the additions below.

### Added

- **PyPI release workflow** — `.github/workflows/release.yml` fires on every `v*` tag pushed to `main`. Runs the test suite, verifies the tag matches `pyproject.toml` and `src/coarse/__init__.py`, builds the sdist + wheel with `uv build`, and publishes to PyPI via Trusted Publishing (OIDC). No API token secret lives in the repo — the `publish` job runs in the `pypi` GitHub environment and mints a short-lived OIDC token that PyPI accepts. Release flow in `CONTRIBUTING.md` is updated with the one-time PyPI setup steps for `Manage → Publishing → Add a new pending publisher`. v1.1.5 is the first release published through this workflow.

### Fixed

- **Web cost estimator realigned with the Python version** — `web/src/lib/estimateCost.ts` was still modeling the pre-v1.1.4 pipeline: 3-judge overview panel, legacy `crossref + critique` chain, 1.15x buffer, no `contribution_extraction` / `completeness` / `proof_verify` / `cross_section` / `editorial` stages, section output budget of 3500, section cap of 40, and no reasoning-model overhead. After PR #47 the Python `src/coarse/cost.py` was deeply updated but the TS sibling was left as a "follow-up PR" TODO. This fix brings it to parity: same stage list as `build_cost_estimate`, same per-stage token budgets (metadata 256, math_detection 1024, calibration 2048, overview 8192, completeness 4096, section 10000, proof_verify 16384, cross_section 8192, editorial 24000), same `COST_BUFFER = 1.30`, same `MAX_REVIEWABLE_SECTIONS = 25`, same `MATH_SECTION_FRACTION = 0.2`, same `EXPECTED_CROSS_SECTION_CALLS = 1` heuristic at ≥6 sections, and a new `isReasoningModel(modelId)` helper that mirrors `REASONING_MODEL_PREFIXES` + `REASONING_MODEL_SUBSTRINGS` from `src/coarse/models.py` and applies the 4x hidden-token overhead to `tokens_out` for reasoning models. `estimateReviewCost` now accepts `modelId` and `isPdf` parameters so the web caller can pass the selected model (for reasoning detection) and the file type (to gate `extraction_qa` to PDFs only, matching `pipeline.py:226`). `web/src/app/page.tsx` updated to forward both. Verified end-to-end: TS estimator returns $1.44 / $9.87 / $16.12 / $307.93 on a 40k-token paper for qwen / Sonnet 4.6 / Opus 4.6 / gpt-5-pro — exact match with the Python estimator post-#47.

## v1.1.4 — 2026-04-11

Correctness and security release. The headline is a deep audit of the cost estimator that found the CLI gate was silently under-quoting every Claude review to ~$0 (the bare `anthropic/*` prefix missed litellm's registry; `openrouter/` keyed entries were hidden behind a one-sided fallback) and modeling a pipeline from two refactors ago (phantom 3-judge overview panel, missing `contribution_extraction` / `completeness` / `proof_verify` / `cross_section` / `editorial` stages, legacy `crossref + critique` still in the primary path). Also lands prompt-injection hardening across every agent that consumes untrusted paper content, webhook and key-scrubbing fixes in the Modal worker, and a fleet of smaller bug fixes in extraction, quote verification, and the LLM wrapper.

### Added

- **`LLMClient.complete_text()` method** — first-class unstructured text completion for models where instructor's structured output makes no sense (e.g. Perplexity Sonar Pro returning prose citations). Routes through the existing `_sanitized_completion` wrapper so control-char stripping and cost tracking apply uniformly with `complete()`.
- **`config.has_provider_key()` helper** — returns whether the named direct provider has a key (env or config file) WITHOUT falling back to `OPENROUTER_API_KEY`, so routing logic can distinguish "user wants to call openai directly" from "user has openrouter and we should proxy". Uses `_clean_env` so whitespace-only keys are still treated as absent.
- **`tests/test_editorial.py`** — fills a rule-6 gap: the canonical editorial filter agent (`agents/editorial.py`) was previously untested, even though it replaced the legacy crossref+critique chain.

### Changed

- **`agents/literature.py` now routes Perplexity through `LLMClient.complete_text`** — previously `_search_perplexity` called `litellm.completion` directly and reached into `llm._inject_openrouter_privacy`, bypassing `LLMClient`'s cost tracking and privacy injection. After the refactor the module only imports `LLMClient`, uses the public API, and the rule-2/rule-8 violations are gone.
- **`cli.py::_pick_cheap_model` respects `~/.coarse/config.toml` keys** — previously iterated `CHEAP_MODELS` and checked `os.environ.get(env_var)`, so users who configured keys only in the config file got "no cheap model found". Now inverts `PROVIDER_ENV_VARS` and calls `has_provider_key(provider, config)` for each entry.
- **`agents/literature.py` arXiv prompts moved to `prompts.py`** — two inline system prompts (`_QUERY_GEN_SYSTEM`, `_RANKING_SYSTEM`) relocated as `ARXIV_QUERY_GEN_SYSTEM` / `ARXIV_RANKING_SYSTEM` to honor the "prompts live in prompts.py only" rule.
- **Landing-page model picker refreshed** — `web/src/components/ModelPicker.tsx` now defaults to `anthropic/claude-opus-4.6` as the pre-selected model and adds `openai/gpt-5.4` and `google/gemini-3.1-pro-preview` as featured options. `qwen/qwen3.5-plus-02-15` was bumped to `qwen/qwen3.6-plus` to reflect the new release on OpenRouter. All four IDs were verified against OpenRouter's live catalog before landing.
- **Cost gate displays materially higher totals** — the buffer bump from 1.15 → 1.30 plus the per-stage budget realignment means CLI users will see ~20–30% higher numbers on qwen/gemini runs and ~5–20× higher numbers on Claude Sonnet/Opus runs. These are closer to the real bill; previous quotes were systematically low. Modal (web) reviews are unaffected — `modal_worker.py` runs with `skip_cost_gate=True` and uses `web/src/lib/estimateCost.ts` for user-facing estimates, which is still on the legacy pipeline and will be realigned in a follow-up PR.

### Fixed

- **Cost estimator: structural drift + under-counts** — `src/coarse/cost.py` was modeling a pipeline from two refactors ago. It (a) still had a phantom 3-judge overview panel + `overview_synthesis` stage that **do not exist** in `agents/overview.py` (the real `OverviewAgent.run()` makes exactly one `client.complete(..., max_tokens=8192)` call), (b) sized `completeness` input at 3000 tokens when the agent actually reads `_build_sections_text(structure.sections)` — i.e. the full paper — via `agents/completeness.py:44`, (c) was missing `contribution_extraction`, `completeness`, `proof_verify`, `cross_section`, and the merged `editorial` stage entirely, and (d) was still modeling the legacy `crossref + critique` chain as the primary path even though `agents/editorial.py` replaced them. Per-stage output budgets were also under-sized vs each agent's real `max_tokens`: section agents 3500 vs 16384 actual (now 10000 median), extraction QA 1000 vs 4096, etc. Fix realigns the stage list with `pipeline.py:review_paper()`, deletes the phantom judge panel, sizes `completeness` input at `total_tokens + overview_context`, bumps each stage's out-token budget to the real `max_tokens`, caps `section_count` at 25 to match `pipeline.py`, gates `extraction_qa` on `is_pdf` so non-PDF formats aren't over-billed, routes extraction QA through a new `_append_model_stage` helper that evaluates reasoning overhead per stage model (not review default), and bumps `_COST_BUFFER` 1.15 → 1.30 to cover the tail. `tests/test_cost-estimator.py` gains 14 new cases pinning the stage list, per-stage output-budget snapshot, boundary edges, PDF-only QA, and explicit not-flagged-reasoning assertions on QA stages. Note: `web/src/lib/estimateCost.ts` is still on the legacy pipeline and will be realigned in a follow-up PR.
- **Cost gate ignored the `--model` CLI override** — `src/coarse/pipeline.py:262` called `run_cost_gate(paper_text, config)` without forwarding the resolved runtime model, so `build_cost_estimate` always queried `config.default_model` regardless of what the user actually asked for. `coarse review paper.pdf --model anthropic/claude-sonnet-4.6` against a config that defaulted to qwen would display a qwen quote and then run the review on Sonnet — a silent ~5× under-quote. Fix threads `resolved_model` through `run_cost_gate(..., model=resolved_model)` → `build_cost_estimate(..., model=...)`.
- **Claude 4.6 pricing now resolves for the bare `anthropic/*` prefix** — `litellm.model_cost` stores `anthropic/claude-sonnet-4.6` and `anthropic/claude-opus-4.6` ONLY under the `openrouter/` prefix. `_lookup_model_cost` in `src/coarse/llm.py` had a fallback that stripped `openrouter/` but never tried *adding* it, so every Claude 4.6 review quoted via the bare form (which is what the web picker passes and what most CLI users type) resolved to `(0, 0)` per-token pricing. For a 40k-token paper on Claude Sonnet 4.6, the pre-fix quote was $0.50 (just the flat-fee OCR + Perplexity + gemini vision QA); post-fix it is **$9.87**. For Claude Opus 4.6: $0.50 → **$16.12**. Fix adds an `f"openrouter/{model}"` fallback lookup to `_lookup_model_cost`.
- **OpenRouter auth: reject whitespace-only keys** — a Modal review failed with 401 `"Missing Authentication header"` cascading through every LLM stage despite `OPENROUTER_API_KEY=set` in the worker log. Root cause: plain truthiness checks (`if or_key:`, `bool(env)`) treated a whitespace-only key as present, so it got forwarded to litellm as `api_key=" "` and produced a literal `Authorization: Bearer ` header — which OpenRouter's 401 response maps *specifically* to "Missing Authentication header" (distinct from "User not found" for bad keys and "No cookie auth credentials found" for no header at all). Fix strips the key at every API-key read site, not just the one that produced the reported incident: new shared `_clean_env()` helper in `config.py` treats whitespace-only env vars as unset; `resolve_api_key()` (the canonical entry point), `_normalize_model()`'s provider-routing gate, `_inject_openrouter_privacy()` (both the env path AND a caller-provided `api_key` kwarg), `agents/literature.py`'s Perplexity dispatch, and `cost.py`'s cost-gate branch all use it. `modal_worker.py` strips `user_api_key` before installing it in the env. The OAuth callback in `web/src/lib/openrouterAuth.ts` trims the key returned from the exchange endpoint and on save/load from localStorage so a stray `\n` can't sneak in on the web side either.
- **Null-byte crashes on Supabase write** — `_sanitized_completion` in `src/coarse/llm.py` stripped literal control characters (`\x00-\x1f`) from raw LLM response text but did not touch the JSON escape sequence `\u0000` (six printable ASCII bytes). When an LLM emitted `\u0000` inside a JSON string field, the escape survived the sanitizer, `json.loads` reconstituted it as a real `\x00` inside the parsed Pydantic field, and the resulting `result_markdown` then crashed the Supabase UPDATE with Postgres 22P05 (`\u0000 cannot be converted to text`), marking the review as failed despite the pipeline having succeeded. Observed in production during the post-launch tweet surge on two gpt-5.4 reviews (`6c41a05e`, `92947b4b`). Fix strips the `\\u0000` escape form before Instructor/Pydantic parses the content.
- **`extraction.py::_classify_api_error` regex-literal bug** — `"invalid.*key" in msg` was doing substring containment on the four characters `.*` rather than regex matching, so an error message containing "Invalid API key" with no HTTP 401 status slipped past the user-facing classification and fell through to the next extraction backend. Fix uses `"invalid" in msg and "key" in msg`. Status 401 always caught the primary case, so no user-visible regression was reported.
- **`llm.py::complete()` MD_JSON floor could exceed model ceiling** — the Kimi/MD_JSON path did `clamped = max(clamped, 16384)` AFTER the initial `_clamp_max_tokens()` call, so a future Kimi variant with a smaller output window could have its max_tokens raised above what the model actually supports. Fix re-clamps after the floor. No live impact with the current Kimi k2.5 (32k output window).
- **`quote_verify.py` Jaccard pre-filter range bound** — the chunk-scan stop was `len(paper_text) - min(quote_len, len(paper_text)) + 1`, so the final iteration produced a truncated trailing chunk whose Jaccard score was distorted by having fewer tokens to compare against. Fix caps the stop at `len(paper_text) - window_size + 1` so every scored chunk is a full window.
- **`quote_verify.py::_is_math_heavy` false positives on prose with dates** — triggered strict math-mode (0.92 fuzzy threshold instead of 0.80) whenever text had three or more numeric tokens, so a prose quote like "Table 3 shows 15.2 in 2020" would flip to strict mode for the wrong reason. Fix requires a 5% density floor on top of the existing ≥3 tokens / ≥10% density condition.

### Security

- chore(deps): bump aiohttp→3.13.5, requests→2.33.1, pygments→2.20.0, litellm→1.83.4 to address 15 CVEs (#35)
- fix(prompts): fence untrusted paper content in PROOF_VERIFY, CONTRIBUTION_EXTRACTION, and PERPLEXITY templates to mitigate prompt injection (#36)
- fix(deploy): use hmac.compare_digest for webhook token check in modal_worker (#41)
- fix(deploy): extend _sanitize_error to cover Groq, Perplexity, and Anthropic key prefixes (#41)
- fix(extraction): scrub bearer tokens and API keys from error messages before raise/log on CLI path (#41)
- fix(prompts): add `_CONTENT_BOUNDARY_NOTICE` to EDITORIAL, CROSSREF, CRITIQUE, ASSUMPTION_CHECK, METADATA system prompts and fence abstract + first-pass comments in proof_verify_user (#42)

## v1.1.3 — 2026-04-11

First release under the new `coarse-ink` PyPI name. `uvx coarse-ink review paper.pdf ...` now works end-to-end. The PyPI version history for `coarse-ink` skips 1.1.1 and 1.1.2 — 1.1.0 was published manually from an earlier branch while the rename was still in draft, and this release catches up to the in-repo version. Also ships new incident monitoring and structured error classification for backend observability.

### Fixed

- **PyPI distribution renamed to `coarse-ink`** — the bare `coarse` name on PyPI was already taken by an unrelated, abandoned package (`coarse==0.0.1` by a different author), so `uvx coarse` / `pip install coarse` resolved to the wrong package and users hit `Package 'coarse' does not provide any executables` (#17). Installing is now `uvx coarse-ink review paper.pdf ...` or `pip install coarse-ink`. The Python import name (`import coarse`) and the `coarse` CLI command are unchanged — a `coarse-ink` console script is also registered so `uvx coarse-ink ...` resolves directly, and `uv tool install coarse-ink` still puts a `coarse` command on your PATH.

### Added

- **Automated incident posting to X/Twitter** — new `deploy/incident_monitor.py` Modal app (`coarse-monitor`) that runs every 2 minutes, watches four independent signals (backend error spike, queue stall, Modal `/health` down, upstream provider 5xx pattern), and posts an "investigating" tweet when the backend is actually broken. Auto-resolves (threaded reply) once signals have been clear for 10 minutes. Explicitly ignores user-side errors (bad API keys, spending limits, 429s from the user's own provider) so it never tweets because someone pasted a dead key. Ships in `INCIDENT_MONITOR_MODE=dry_run` by default; flip to `live` via Modal secret once dry-run logs look right. Includes 30 min cooldown between incidents to suppress flapping and an `INCIDENT_MONITOR_ENABLED=false` kill switch.
- **Structured error classification on failed reviews** — new `reviews.error_category` column distinguishes `user_auth` / `user_quota` / `user_forbidden` / `user_rate_limit` from `provider_5xx` / `extraction` / `backend_timeout` / `backend_unknown`. Classifier lives in `deploy/error_classify.py` so the incident monitor can query it without re-parsing unstructured `error_message` strings. Requires `deploy/migrate_incidents.sql` to be applied to Supabase.

## v1.1.2 — 2026-04-11

Internal tooling release. No user-facing changes to the `coarse` package on
PyPI — this release ships dev-workflow automation (security scanner, slash
commands, pre-commit hooks, CI gates) plus a small refactor that re-aligns
`src/coarse/llm.py` with the "model IDs live in `models.py` only" rule.

### Fixed

- **`llm.py` no longer hardcodes model IDs** — the `_CUSTOM_MODEL_INFO` dict keyed on literal `"qwen/qwen3.5-plus-02-15"` and `"moonshotai/kimi-k2.5"` now imports `DEFAULT_MODEL` and the new `KIMI_K2_5_MODEL` constant from `coarse.models`, restoring the "model IDs live in `models.py` only" invariant from CLAUDE.md. The security scanner's `hardcoded-model-id` check was also extended to cover `moonshotai`, `kimi`, `groq`, `together`, `xai`, and `cohere` provider prefixes so future drift gets caught.

### Added

- **Security scanner + `/security-review` command** — new zero-dep `scripts/security_scanner.py` (stdlib only) scans the repo for known leaked secret fingerprints (stored as SHA-256 hashes, never plaintext), provider-key patterns (OpenAI / Anthropic / Google / OpenRouter / Perplexity / Supabase / Stripe / GitHub / AWS / private keys / URL-embedded creds), insecure `.env` permissions, and model-ID string literals outside `src/coarse/models.py`. Skips docstrings via AST and supports inline `# security: ignore` suppression. Backed by 14 tests in `tests/test_security.py` (synthetic fingerprint via monkeypatch — no real key material in the test fixtures). Runs in CI (`.github/workflows/security.yml --strict`), from `make security`, and from the `/security-review` slash command which adds 3 parallel in-session agents for API-key lifecycle, HTTP surface, and prompt-injection audits.
- **`/architecture-review` command** — lightweight macro-level structural review with inline AST-based import-graph analysis (cycles, layer violations, oversized files, fan-in leaders) plus 3 parallel in-session agents for coupling, data flow, and simplification.
- **`/module-review` command** — focused per-module audit against coarse's 11-point bug checklist (model-IDs-only-in-models.py, LLMClient wrapper, instructor/Pydantic, prompts centralization, no-print, test coverage, context managers, cost tracking, resolve_api_key, extraction robustness, quote verification). Supports `--module`, `--changed`, `--all`, `--review-only`.
- **`scripts/doc-sync-check.sh`** — lightweight bash sync check used by `/pre-pr`: verifies every `src/coarse/*.py` is listed in CLAUDE.md, CHANGELOG has an `## Unreleased` section, `pyproject.toml` version matches `src/coarse/__init__.py`, and no new model-ID literals slipped into the diff outside `models.py`.
- **Pre-commit hooks** — `.pre-commit-config.yaml` wires ruff, ruff-format, trailing-whitespace, end-of-file-fixer, check-yaml/toml, large-file guard, private-key detector, merge-conflict check, plus the local security scanner. Install with `make install-hooks`.
- **CI security workflow** — `.github/workflows/security.yml` runs the scanner in `--strict` mode on every push and PR to `main`/`dev`, plus advisory `pip-audit`.
- **CLAUDE.md: Worktree Discipline + Parallel Development sections** documenting the `/private/tmp/coarse-<slug>/` pattern, full-path rule for Edit/Write, and the 4-concurrent-worktree cap for parallel Claude Code sessions.
- **CLAUDE.md: Slash Commands section** listing every command and its purpose.
- **Makefile targets**: `make security`, `make install-hooks`.

### Changed

- **`/pre-pr` upgraded** — new Step 0 is a BLOCKING security gate via `security_scanner.py` (CRITICAL halts, `--strict` also halts on HIGH); new Step 1.5 runs `doc-sync-check.sh`; git diff base fixed from `main...HEAD` to `dev...HEAD` (feature PRs target `dev` per CONTRIBUTING.md); added a 6th review agent that re-runs the scanner against the diff and checks new code for `os.getenv("*_API_KEY")` bypasses, missing HTTP timeouts, and prompt-injection-unsafe templates.
- **`/worktree-start` bases worktrees off `dev`** (was `main`). Worktree slug format now `coarse-<issue>-<slug>`. Reminder output mentions the shell-cwd-resets-between-Bash-calls trap and the `gh pr create --base dev` convention.
- **CLAUDE.md package structure** — added the 5 agents that had drifted off the tree (`completeness.py`, `cross_section.py`, `editorial.py`, `contradiction.py`, plus top-level `recall.py`). Marks `crossref.py`, `contradiction.py`, `critique.py` as legacy (superseded by `editorial.py`).
- **Stripped 10 cached curl commands** containing inline Supabase/OpenRouter secrets from `.claude/settings.local.json` (local-only file, never tracked in git, `.gitignore` already covers `.claude/`). Tightened `.env` and `web/.env.local` permissions from 0o644 → 0o600.

## v1.1.1 — 2026-04-10

### Added

- **Web: Log in with OpenRouter** — the submission form now has a "Log in with OpenRouter" button that runs OpenRouter's browser-only OAuth PKCE flow and fills in the API key automatically. The returned key is stored in the browser's localStorage so users stay logged in across visits; a "Log out" control clears it. The manual paste field is kept as a fallback for users who prefer to use a scoped key. The server contract is unchanged — the key is still only sent in the `/api/submit` body when a review is submitted and never persisted server-side. Closes #12.

### Changed

- **Mistral OCR is now OpenRouter-only** — removed the `_extract_mistral_direct` backend that tried to hit Mistral's API directly with a `MISTRAL_API_KEY`. All PDF OCR now routes through OpenRouter's `file-parser` plugin, so users only ever need an `OPENROUTER_API_KEY`. The OCR → Docling fallback chain is unchanged for offline use.
- **Raised Modal concurrency from 10 → 20 containers**, removed the web-side hard rejection gate so bursts beyond the container limit are queued by Modal instead of erroring out.
- **OpenRouter OCR retry budget raised from 5 to 10 attempts** with a 32s per-retry backoff cap. Sequence is now 1s + 2s + 4s + 8s + 16s + 32s × 4 = 159s max, giving much more headroom for Mistral OCR's upstream timeouts while keeping the total wait bounded. A new cost guard inspects the `usage` field of every 200-with-error-body response and refuses to retry if the request was actually billed, so the retry bump can't multiply the user's API cost.
- **PDF extraction chain now has three tiers: Mistral OCR → pdf-text (OpenRouter) → Docling.** The new middle tier uses OpenRouter's free `pdf-text` file-parser engine, which extracts embedded PDF text without involving Mistral OCR at all. It catches Mistral OCR upstream outages (persistent 504 timeouts) without paying Docling's torch/RapidOCR startup cost, and falls through to Docling for scanned image-only PDFs.
- **Modal function memory raised from 2 GB to 4 GB.** Large PDFs (8+ MB) plus the Docling torch/RapidOCR stack can exceed 2 GB when OCR falls through to offline mode. 4 GB keeps the offline fallback usable for big documents.
- **PDFs are no longer deleted from Supabase Storage on the `finally` path** of the Modal worker. They're only deleted on the success path now; failed reviews keep their PDF so Modal's infrastructure retries (or a manual resubmit of the same job_id) can actually find it. Stale PDFs from failed runs are swept by the daily cleanup cron at 24h.

### Fixed

- **Docling fallback works in production** — added `libgl1` + `libglib2.0-0` to the Modal image. Without them, Docling crashed on first import with `libGL.so.1: cannot open shared object file`, meaning the "offline fallback" wasn't actually a fallback when the OpenRouter OCR path failed.
- **OpenRouter OCR error handling** — when OpenRouter returns HTTP 200 with an error body (rate limit, plugin unavailable, content policy), extraction no longer crashes with `KeyError: 'choices'`. Instead it raises a clean `ExtractionError` with the provider's error message, which the orchestrator can then classify into a user-facing message. Also handles missing `choices`, malformed annotations, empty content, and non-dict JSON responses.
- **OpenRouter OCR retry logic** — added bounded retries (3 → 5 attempts, exponential backoff 1s/2s/4s/8s/16s) on connection errors, read timeouts, and transient HTTP statuses (408, 429, 500, 502, 503, 504). Non-transient 4xx errors (401 bad key, 402 spend limit) fail fast without wasting retries. Diagnostic logging on every retry and every unexpected response shape.
- **Retry OpenRouter OCR when the error is wrapped in a 200 response body.** The file-parser plugin occasionally returns `HTTP 200` with `{"error": {"code": 504, "message": "Timed out parsing ..."}}` instead of a raw `504`. The previous retry logic only matched on transport status codes, so it treated these as immediate failures. Now retries whenever the body's `error.code` is in the same retryable set (408, 429, 500, 502, 503, 504).
- **Modal-level retries work again.** Previously, if Modal infrastructure retried a review (after an OOM or container crash), the retry would 404 on the PDF because the `finally` block had already deleted it from Supabase Storage. See the PDF storage change above.
- **Extraction QA no longer logs spurious "Page N out of range" warnings.** When Mistral OCR emitted more `<!-- PAGE BREAK -->` markers than the PDF had pages (trailing empty chunks, or a page split mid-content), `_select_qa_pages` could propose a page number that fitz rejected. Now clamps scored page numbers and the final selection set to `num_pages`.
- **Math section detection logs the underlying exception** instead of a bare "failed" message, so transient LLM errors are actually diagnosable from the Modal worker logs.
- **Math section detection no longer silently fails for Claude-family models.** The LLM call passed `max_tokens=256`, which was fine for Qwen/DeepSeek but too tight for Claude 4-family models (Opus, Sonnet) that sometimes write a prose preamble before emitting the tool call. At 256 the preamble alone could hit `finish_reason='length'` and instructor raised `InstructorRetryException("The output is incomplete due to a max_tokens length limit.")` before any JSON was produced, forcing the keyword-heuristic fallback. Bumped to 1024 and tightened `MATH_DETECTION_SYSTEM` to tell the model to skip the preamble and emit the structured response directly. Diagnosed from real user review `1e786d50`.

## v1.1.0 — 2026-04-10

### Added

- **Multi-format support** — `extract_file()` router supports .txt, .md, .tex, .latex, .html, .docx, and .epub in addition to PDF. Quality-first strategy: Docling (if installed) for .docx/.html/.tex, with lightweight fallbacks (mammoth, markdownify, regex). PDFs always use Mistral OCR first. New optional dependency group: `pip install coarse[formats]` for .docx/.html/.epub fallback support; .txt/.md/.tex require no extra dependencies.
- **No-retention provider routing** — All OpenRouter-routed requests now include `provider.data_collection: "deny"`, instructing OpenRouter to route only to model providers that do not retain or train on user data. No-op for direct Anthropic/OpenAI/Google API calls.

### Changed

- **Literature search: Perplexity Sonar Pro** — Primary literature search now uses `perplexity/sonar-pro-search` via OpenRouter for web-grounded results with real citations (~12s, ~$0.03). Returns both related work and open questions/known limitations. arXiv pipeline kept as automatic fallback when OPENROUTER_API_KEY is unavailable or Perplexity fails.

### Fixed

- **OpenRouter auth: pass `api_key` explicitly** — `_inject_openrouter_privacy()` now also injects `OPENROUTER_API_KEY` from env into the litellm call kwargs for `openrouter/*` models. Fixes a production failure on Modal where reviews aborted with `401 "Missing Authentication header"` despite the env var being set at the moment of the check. Relying on litellm's implicit env-var lookup was flaky; passing the key directly is deterministic.

## v1.0.0 — 2026-03-09

First public release.

### Added

- **Web hosting infrastructure** — Vercel (Next.js) + Supabase (auth/DB/storage) + Modal (serverless compute) architecture for hosted web version. Two tiers: free (data-sharing consent for research) and BYOK (user's own OpenRouter key).
- **Mistral OCR extraction** — Primary PDF extraction uses Mistral OCR via litellm (94% math accuracy). Docling kept as offline fallback. Priority: MISTRAL_API_KEY direct → OpenRouter file-parser plugin → Docling.
- **Post-extraction QA** (`extraction_qa.py`) — Vision LLM spot-checks extraction output against page images for papers with figures/tables.
- **Model manifest** (`models.py`) — Single source of truth for all model IDs.
- **Quote verification** (`quote_verify.py`) — Post-processing fuzzy match of comment quotes against paper text; drops or flags unverifiable quotes.
- **Domain calibration** — Generates domain-specific review criteria from paper content.
- **Literature search** — arXiv-based literature context for review agents.
- **CI pipeline** (`.github/workflows/ci.yml`) — Ruff lint (advisory), pytest (blocking), version consistency check on every PR.
- **Evaluation data** (`data/refine_examples/`) — Reference reviews and papers for quality scoring.

### Changed

- **Anthropic prompt caching** — Overview panel judges share cached paper context via `cache_control`, reducing input token costs ~90% on cache hits. Judges run sequentially for Anthropic models to ensure cache sharing. Section/crossref/critique agents also annotate system prompts for caching.
- **Structured assumption cross-check** — 4-step procedure for cross-checking theoretical assumptions against empirical methodology.
- **Appendix proof coverage** — Appendix sections classified as "proof" are now reviewed instead of skipped.
- **Anti-redundancy prompts** — Section agents instructed not to restate overview issues; crossref dedup removes comments that duplicate overview issues.
- **Anti-truncation quote instructions** — Strict "NEVER truncate mid-equation" rule in all section prompts.
- **Quote verify expansion** — `_trim_to_best_match` uses a 1.5x window to expand truncated quotes instead of re-truncating correct matches.
- **Optional heavy dependencies** — `docling` moved to optional extra (`pip install coarse[docling]`). Core install is lightweight.

### Fixed

- **LaTeX garbling in review quotes** — Added `verify_quotes` call after critique agent (which re-garbles LaTeX via JSON round-trip). Added LaTeX preservation instructions to all section prompts.

### Removed

- **Agentic mode** — Removed `--agentic` CLI flag, coding agents, and `openai-agents` dependency. Standard LLM agents provide comparable quality with simpler architecture.
- **Vision-based structure extraction** — Removed `--vision` CLI flag. Structure extraction via markdown heading parsing instead.
- **pymupdf4llm dependency** — Replaced by `docling>=2.0`. pymupdf kept for extraction QA page rendering.

## v0.1.0 — 2026-03-03

Initial release.

### Features

- **PDF extraction** (`extraction.py`): Convert PDF papers to text using pymupdf4llm; optional
  vision mode for scanned or image-heavy PDFs.
- **Structure analysis** (`structure.py`): LLM-based parsing of paper into title, domain,
  taxonomy, abstract, and labelled sections with claims and definitions.
- **Overview agent** (`agents/overview.py`): Produces 4-6 macro-level issues covering the paper
  as a whole (runs in parallel with section agents).
- **Section agents** (`agents/section.py`): One agent per section produces 15-25 detailed
  comments with verbatim quotes from the paper.
- **Cross-reference agent** (`agents/crossref.py`): Deduplicates comments, validates quotes, and
  checks consistency across agents.
- **Critique agent** (`agents/critique.py`): Self-critiques the draft review and revises weak
  comments as a quality gate.
- **Pipeline** (`pipeline.py`): Orchestrates extraction, structure analysis, all agents, and
  synthesis into a single `review_paper()` call.
- **Synthesis** (`synthesis.py`): Deterministically renders a `Review` object to structured
  markdown format.
- **LLM layer** (`llm.py`): litellm wrapper with instructor for structured Pydantic output; tracks
  per-call cost.
- **Cost estimation** (`cost.py`): Pre-flight cost estimate with a configurable spending cap and
  interactive user approval gate.
- **Config management** (`config.py`): Reads and writes `~/.coarse/config.toml`; resolves API
  keys from environment variables or the config file.
- **CLI** (`cli.py`): `coarse setup` for interactive configuration; `coarse review paper.pdf` for
  reviewing a paper; `--model`, `--output`, and `--yes` flags.
- **Quality eval** (`quality.py`): Developer-only tool that scores a generated review against a
  reference review using an LLM judge.
- **Type definitions** (`types.py`): All shared Pydantic models (`PaperText`, `PaperStructure`,
  `Review`, `DetailedComment`, `CostEstimate`, etc.).
- **Prompt templates** (`prompts.py`): All LLM prompt templates in one place.
