---
name: ship-readiness
classification: epic
success_metric: tcg-vault is safe to expose to anonymous internet traffic with a documented launch checklist green
skip: []
status: open
created: 2026-05-22
---

# Ship-readiness convoy

Umbrella convoy capturing the full agent-pipeline review of tcg-vault as of 2026-05-22. Findings are grouped by L2 role lens (Reviewer / Architect / Design-system / A11y / IA / Doc-writer) and severity. Each item points to the convoy that will execute the fix.

Code graph: 122 files, 628 nodes, 5602 edges, 11 communities. Indexed by `user-code-review-graph` MCP.

## Status summary (as of 2026-05-24)

**P0 ship-blockers: 8 of 8 RESOLVED. Launch-readiness P0 checklist is empty.**

| Item | Status | Convoy |
| --- | --- | --- |
| P0 #1 — `getUserFromRequest` hardcoded admin | **RESOLVED** 2026-05-23 | `fix-auth-bypass` Brief 2 (`258e479`) |
| P0 #2 — `JWT_SECRET` hardcoded fallback | **RESOLVED** 2026-05-23 | `fix-auth-bypass` Brief 1 (`4a10dce`) |
| P0 #3 — Default admin credentials in seed | **RESOLVED** 2026-05-23 | `drop-public-setup` (`ff80753` + `b63b509`) |
| P0 #4 — Dev-only test endpoints | **RESOLVED** 2026-05-23 | `fix-auth-bypass` Brief 3 (`fc0dd73`) |
| P0 #5 — Wildcard CORS on API surface | **RESOLVED** 2026-05-24 | `fix-auth-bypass` Brief 4 (`297afca`) + `cors-tighten` (`da50d78`) |
| P0 #6 — No rate limiting | **RESOLVED** 2026-05-24 | `fix-auth-bypass` Brief 4 (`297afca`, login + register) + `add-rate-limiting` (`708ef45`, the remaining surface) |
| P0 #7 — Layout default-prop leaks email | **RESOLVED** 2026-05-24 | `fix-layout-default-user` (`ca302a8`) |
| P0 #8 — Next.js 15.4.3 vulnerable version | **RESOLVED** 2026-05-23 | `bump-next-js` (`e57ea17`) |

**Milestone reached 2026-05-24:** `add-rate-limiting` (PR #20, squash
commit `708ef45`) closed P0 #6 — the last open P0 — flipping the
ship-blocker set from 7/8 to **8/8 RESOLVED**. The security gate is
closed; P1 quality bar is **6/6 RESOLVED** (lint baseline cleared 2026-06-02).
Remaining launch work is P2 / P3 polish in this file's Queued convoys section.
None of those are P0 ship-blockers.

**P1 quality-bar: 6 of 6 RESOLVED** (last item closed 2026-06-02). The
7-convoy multitask wave merged 2026-05-26 (PRs #26–#32) closed P1 #8, #9,
and #11:
- **P1 #8 single-sql-client** → RESOLVED 2026-05-26 (PR #30, squash `c403ea4`).
- **P1 #9 single-auth-provider** → RESOLVED 2026-05-26 (PR #31, squash `0668b0c`).
- **P1 #11 migration-tool** → RESOLVED 2026-05-26 (PR #32, squash `de9f334`).

Combined with prior closures (P1 #10 via `adopt-vitest` + `adopt-playwright-smoke`
PR #18; P1 #12 via `pick-a-name` PR #21) and **P1 #11.5 `fix-lint-baseline`**
(PRs #61–#63, 2026-06-02), the P1 lane is complete. Lint is **0 problems**;
CI `lint` is blocking (no `|| true` / `continue-on-error`).

## P0 — ship-blockers (security)

These MUST land before any anonymous traffic touches the production URL.

### 1. `getUserFromRequest` returns a hardcoded admin when no Bearer token is present — **RESOLVED 2026-05-23**

- **Resolved by:** `fix-auth-bypass` Brief 2, commit `258e479` (PR #8). Follow-up Brief 6 hotfix `1fca3aa` added explicit 401 guards to the cards-collection POST/PUT/DELETE branches that previously masked the bug as 500s.
- **File:** `lib/permission-middleware.js` lines 13-17.
- **Impact:** Every API route that calls `getUserFromRequest` (30+ handlers — see `user-code-review-graph` cross-community edges from `api-handler` → `lib-admin`) accepts unauthenticated requests as admin user 1.
- **Repro:** `curl https://<host>/api/collections` with no `Authorization` header returns admin's collections.
- **Fix:** Delete lines 13-17. Return `null` when no Bearer token. Update every caller to handle `null` properly (most already do; the broken fallback was masking the right path).
- **As-shipped:** The helper now returns `null` for any unauthenticated request. 16 unit tests in `test/lib/permission-middleware.test.js` lock in the contract (including a negative regression against the old synthetic-admin shape). `pages/api/auth/verify.js` returns 401 on the no-token branch instead of fetching the seed admin row.
- **Owns:** `role-architect` + `role-implementer` (one PR; small surface area in the helper, callers already check `!user`).

### 2. JWT_SECRET hardcoded fallback in 7 files — **RESOLVED 2026-05-23**

- **Resolved by:** `fix-auth-bypass` Brief 1, commit `4a10dce` (PR #7).
- **Files:**
  - `pages/api/auth-utils.js` (`'your-secret-key'`)
  - `pages/api/auth/login.js`, `pages/api/auth/register.js`, `pages/api/auth/verify.js`
  - `pages/api/favorites.js`, `pages/api/users/search.js`
  - `lib/permission-middleware.js`
- **Impact:** If `JWT_SECRET` env var is unset (e.g. preview/staging misconfig), tokens are signed with `'your-secret-key-change-in-production'` — an attacker can sign their own admin token in 5 seconds.
- **Fix:** Centralize JWT_SECRET access in one helper that `throw`s at module load if `process.env.JWT_SECRET` is unset. Every other file imports from there.
- **Bonus:** Token expiry is inconsistent (`/api/auth/login.js` uses 24h, `pages/api/auth-utils.js` uses 7d). Pick one.
- **As-shipped:** `lib/auth-secret.js` is the single source of truth and throws at module load if `JWT_SECRET` is unset. Canonical TTL is `JWT_TOKEN_TTL = '24h'`. All 7 literal fallback sites are converted to import-and-throw. `test/lib/auth-secret.test.js` (3 tests) covers the fail-loud path.
- **Owns:** `role-architect` + `role-implementer`.

### 3. Default admin credentials in seed + README — **RESOLVED 2026-05-23**

- **Resolved by:** `drop-public-setup` Brief 1 (commit `ff80753`) + Brief 2 (commit `b63b509`). PR #13.
- **Files:**
  - `scripts/setup-neon-db.js` lines 130-138 — creates `admin@tcgvault.com` / `admin123`
  - `README.md` documents the credentials
  - `pages/api/setup-database.js` — duplicates the setup AND is an UNAUTHENTICATED public POST endpoint with `Access-Control-Allow-Origin: *`
- **Impact:** Anyone who hits `/api/setup-database` can re-trigger DDL. The `admin123` password is one Google away from public knowledge.
- **Fix:**
  1. Delete `pages/api/setup-database.js`. Schema setup is a one-time job; it should not be a route.
  2. Change `setup-neon-db.js` to require a `ADMIN_INITIAL_PASSWORD` env var (no default).
  3. Strip the admin password from README — replace with "run `npm run setup-db` and follow the prompt".
- **As-shipped:**
  1. `pages/api/setup-database.js` already deleted by `fix-auth-bypass` Brief 3 (commit `fc0dd73`); the `forbidden-endpoints` CI job blocks re-introduction.
  2. `scripts/setup-neon-db.js` now reads `ADMIN_INITIAL_PASSWORD` from `process.env`; if unset or empty, the script writes an actionable error (names the env var, points at `.env.local`, suggests `openssl rand -base64 24`, mentions CI-secret alternative, references README) and exits with code 1 **before** opening any DB connection. The bcrypt input is the env-var value, not the literal `admin123`. The two `console.log` lines that previously echoed `Admin User: admin@tcgvault.com` + `Admin Password: admin123` are deleted (R3 — stdout-leak prevention into CI logs); replaced with a single `Admin user ready (email: admin@tcgvault.com)` line that does NOT echo the password.
  3. `README.md`'s "Default Admin Account" section replaced with "First-time admin setup" copy that documents the env-var requirement, the `openssl rand -base64 24` generation tip, the CI-secret alternative, and an operator-rotation note for envs that pre-date this convoy.
  4. **Bonus (Decision D, Brief 2):** `scripts/setup-neon-db.js` converted from CommonJS to ESM so `npm run setup-db` actually executes on Node 22.x. The `bump-next-js` convoy added `"type": "module"` to `package.json` for ESLint v9 flat config; the seed script's `require()` calls were silently broken since that landed. Without Brief 2, Brief 1's env-var gate would have been theatrical (script throws `ReferenceError` before reaching the gate).
- **Operator caveat (R1, Decision A — going-forward only):** the seed is idempotent (`ON CONFLICT (email) DO NOTHING`); re-running `npm run setup-db` on an env that already has the admin row does NOT rotate the password. Any deployed env that ran setup before this convoy still has the weak `admin123` hash in its DB — operators must rotate manually via the app's profile settings, or wait for the queued `rotate-default-admin` follow-up convoy. Documented in `AGENTS.md` Gotcha #4 and the README's First-time admin setup blockquote.
- **Sibling weak-cred references deferred:** `scripts/reset-db.js`, `scripts/create-test-users.js`, and `TESTING_GUIDE.md` still hardcode `admin@tcgvault.com` / `admin123` — out of scope here per the no-go-zones rule (historical scripts) and the convoy spec. Queued for `purge-weak-creds-from-helpers` follow-up (or fold into `pick-a-name` since the email is also changing).
- **Owns:** `role-implementer`.

### 4. Dev-only test endpoints shipped to production — **RESOLVED 2026-05-23**

- **Resolved by:** `fix-auth-bypass` Brief 3, commit `fc0dd73` (PR #6).
- **Files:** `pages/api/simple.js`, `pages/api/test-auth.js`, `pages/api/test-db.js`, `pages/api/setup-database.js`.
- **Impact:** Unknown — depends on what they expose. `/api/test-db` likely returns the DB connection string; `/api/test-auth` may leak token-handling details.
- **Fix:** Delete all four. Add a CI grep that fails the build if any file matching `pages/api/(test-|simple|setup-)*.js` exists.
- **As-shipped:** All four files deleted. `.github/workflows/ci.yml` has a new `forbidden-endpoints` job (blocking) that fails the build if any of the four paths reappear OR if a new `pages/api/test-*.js` file is added. Local simulation in the implementer PR confirmed clean → OK, with `test-fake.js` → FAIL, post-cleanup → OK.
- **Owns:** `role-implementer`.

### 5. CORS `Access-Control-Allow-Origin: *` on auth endpoints — **RESOLVED 2026-05-24**

- **Resolved by:** `fix-auth-bypass` Brief 4, commit `297afca` (PR #9, login + register) + `cors-tighten`, squash commit `da50d78` (PR #19, the remaining 24 handlers + CI regression-lock).
- **Files:** at minimum `pages/api/auth/login.js`, `pages/api/auth/register.js`, `pages/api/setup-database.js` (verify others).
- **Impact:** Any origin can submit credentials. Combined with the no-rate-limit problem below, credential stuffing is wide open.
- **Fix:** Set `Access-Control-Allow-Origin` to the literal frontend origin (`https://tcgvault.com` / preview domain), or remove the header entirely if the API and the frontend are same-origin (they are, on Vercel).
- **As-shipped (Brief 4, 2026-05-23):** `pages/api/auth/login.js` and `pages/api/auth/register.js` dropped the four `setHeader` calls + the OPTIONS preflight handler. `pages/api/setup-database.js` was deleted entirely by Brief 3.
- **As-shipped (`cors-tighten`, 2026-05-24, squash commit `da50d78`, PR #19, architect-commit `ec22b70`, implementer-commit `a843736`):**
  1. **24 `pages/api/**` handlers swept** — `admin/index.js`, `auth/verify.js`, `cards/[id]/ownership.js`, `cards/owned.js`, `cards/search.js`, `collections.js`, `collections/[identifier].js`, `collections/[identifier]/activity.js`, `collections/[identifier]/cards.js`, `collections/[identifier]/permissions.js`, `collections/[identifier]/thumbnails.js`, `community/collections.js`, `favorites.js`, `invite/accept.js`, `invite/decline.js`, `public/collections.js`, `user/avatar.js`, `user/avatar/generate.js`, `user/delete.js`, `user/password.js`, `user/profile.js`, `user/settings.js`, `user/stats.js`, `users/search.js`. Each diff is a pure deletion of 9-11 lines (the leading `// Set CORS headers` comment + 3 `setHeader` calls + the leading `// Handle preflight requests` comment + the 4-line OPTIONS-if block + the trailing blank line). No additions per source file. **Pattern split: 16 Pattern A (top-level method gate after the CORS block) + 8 Pattern B (method-branched inside the `try` block).** Both shapes documented verbatim in `.convoys/cors-tighten/brief-1-sweep-wildcard-cors.md`.
  2. **New blocking `forbidden-cors-headers` CI job** in `.github/workflows/ci.yml`, modeled verbatim on the existing `forbidden-endpoints` job (added by `fix-auth-bypass` Brief 3). Greps `pages/api/` for `Access-Control-Allow-(Origin|Methods|Headers)`, emits `::error file= line=::` annotations on hit, exits 1. No `continue-on-error`, no `|| true` wrapper. Sits between `forbidden-endpoints` and `test` in the YAML for logical grouping (both `forbidden-*` checks are static-source guards before the runtime test job). Runs in ~4 seconds; zero new dependencies.
  3. **All five architect decisions self-ratified at gate 1** (no operator decisions needed) — D1 Option B (expanded sweep, all 24 files), D2 delete the OPTIONS preflight handler entirely (Option (a)), D3 `verify.js` `Allow-Methods` tightening moot (subsumed by D2), D4 no new per-route handler tests in this convoy (deferred to queued `fill-vitest-handler-coverage`), D5 add the new CI regression-lock job.
  4. **Diff: 25 files, +29 / -261** (pure deletion across 24 source files; 29 additions = the new CI job).
- **As-shipped metrics (post-merge run 26378806555 + subsequent runs):**
  - `forbidden-cors-headers` (new) — PASS in **4s**. First live exercise of the regression-lock; greps clean against the post-sweep tree.
  - `Playwright smoke` — PASS in **56s, 3/3 tests in 3.3s** against the post-CORS-removal Vercel preview. Cross-validates that CORS removal is safe for the auth surface (smoke's sign-in check still passes against `/login`; `/api/health` still serves anonymously). Surfaced as a real CI signal even though Decision D4 deferred per-route handler tests — the existing smoke spec transitively defends the auth + public surfaces against this convoy's deletions.
  - `Screenshot diff` — workflow exited 0 because of `continue-on-error: true`, but the actual visual test failed with the documented "snapshot doesn't exist" error (Decision-4 end state of `adopt-playwright-smoke`). Triggered on PR #19 despite this being API-only because its `paths:` filter is `pages/**` which matches `pages/api/**` too — minor false-positive queued as `tighten-visual-diff-path-filter` (see § Queued convoys).
  - All other gates (`Lint`, `Vitest`, `Schema map up to date`, `forbidden-endpoints`) — green.
- **Implementer subagent-retry footnote (transient).** The implementer's PR report flagged that HEAD was already at the implementer commit (`a843736`) when its retry subagent woke up — a prior implementer run had completed the work, and the retry's "STOP per branch mismatch" rule kicked in; the retry then ran verification only (lint baseline, vitest 21/21, grep clean, YAML valid) and reported success. This is a transient subagent retry, not a process gap. The implementer commit `a843736` is canonical; the squash `da50d78` rolls up the architect plan + Brief 1 + the implementer's work without duplication.
- **Operator action required going forward:** **none.** No env vars to seed, no secrets to rotate, no infra changes. The `forbidden-cors-headers` job is self-contained (plain bash grep on the runner); future PRs that accidentally re-scaffold a wildcard CORS header will fail the build with a file-and-line pointer to the offending line.
- **Owns:** `role-implementer`.

### 6. No rate limiting anywhere — **RESOLVED 2026-05-24** *(milestone — last P0 closed)*

- **Resolved by:** `fix-auth-bypass` Brief 4, commit `297afca` (PR #9, login + register only) + `add-rate-limiting`, squash commit `708ef45` (PR #20, the remaining surface + 3-import-route gating + 1 atomic admin UI fix + 1 rule extension).
- **Impact:** Login endpoint accepts unlimited attempts; card-search endpoint can be hammered; image upload endpoints can be exhausted. The `pages/api/cards/import-*.js` endpoints externally hit Scryfall/Pokémon APIs with no caller throttling.
- **Fix:** Adopt `@upstash/ratelimit` (free tier covers a small launch) or Vercel's built-in middleware-based rate limiting. Apply to: `/api/auth/login`, `/api/auth/register`, `/api/users/search`, `/api/cards/search`, all `/api/cards/import-*`, and `/api/user/avatar*` (upload).
- **As-shipped (Brief 4, 2026-05-23):** `lib/rate-limit.js` (new) provides `checkAuthRateLimit(req)` via `@upstash/ratelimit@^2.0.8` + `@upstash/redis@^1.38.0` (5 attempts / 15-min sliding window per IP). Wired into login + register. Env vars are `KV_REST_API_URL` / `KV_REST_API_TOKEN` (auto-provisioned by Vercel's Upstash Marketplace integration — note this is a rename from the brief's original `UPSTASH_REDIS_REST_*` spec; see `.convoys/fix-auth-bypass/brief-4-tighten-auth-surface.md` § Post-merge addendum). Fails closed in prod when env vars are unset; warn-and-no-ops in dev. Search / import / avatar endpoints were unchanged at that point (the deferred surface that `add-rate-limiting` then closed).
- **As-shipped (`add-rate-limiting`, 2026-05-24, squash commit `708ef45`, PR #20, architect-commit `60b842e`, implementer-commit `51a3a97`):**
  1. **`lib/rate-limit.js` refactored** from a single auth-only `Ratelimit` instance into a `Map<className, Ratelimit>` cache with one shared Redis client and **5 `Ratelimit` instances** (one per class, distinct Redis prefix). Five exported functions: `checkAuthRateLimit(req)` (Brief 4 contract preserved byte-identical), `checkSearchRateLimit(req)`, `checkUploadRateLimit(req, userId)`, `checkGenerateRateLimit(req, userId)`, `checkImportRateLimit(req, userId)`. Internal `check(className, identifier)` shared helper. `LIMITER_CONFIG` is a top-level `const` map of `{ limit, window, prefix }` per class; adding a sixth class is a one-line addition + one new exported function.
  2. **6 routes newly gated** with the appropriate per-class limiter at the correct ordering (auth before user-keyed limiter; method check first):

      | Limiter | Limit/window | Key | Routes |
      | --- | --- | --- | --- |
      | `checkAuthRateLimit` (unchanged from Brief 4) | 5 / 15 min | IP | `auth/login.js`, `auth/register.js` |
      | `checkSearchRateLimit` (new) | 60 / 1 min | IP | `users/search.js`, `cards/search.js` |
      | `checkUploadRateLimit` (new) | 10 / 1 hour | user | `user/avatar.js` |
      | `checkGenerateRateLimit` (new) | 5 / 1 hour | user | `user/avatar/generate.js` |
      | `checkImportRateLimit` (new) | 5 / 1 hour | user (admin-only) | `cards/import-mtg.js`, `cards/import-pokemon.js`, `cards/import-lorcana.js` |

  3. **`extractUserIdentifier(userId)` THROWS** on `null` / `undefined` / `''` / `NaN` (Decision 4 defensive shape). Surfaces gate-ordering bugs at dev time rather than silently falling back to IP and converting a per-user limit into a per-IP limit — which would lock other household members out for one user's behavior. Numeric `0` is intentionally accepted (returns `'user:0'`) for forward-compat.
  4. **Three `pages/api/cards/import-*.js` routes newly auth-gated.** Each grew `getUserFromRequest` + `if (user.role !== 'admin') return 403` + `checkImportRateLimit(req, user.userId)` before the existing `try` block. Closes the publicly-callable anonymous-abuse vector the architect's pre-brief audit flagged (each handler hits Scryfall / Pokémon-TCG / Lorcana APIs and performs UPSERTs into `cards` with no caller throttling pre-convoy). Lorcana was gated defensively despite zero current frontend callers — route removed in PR #59 (`delete-dead-lorcana-import`, RESOLVED 2026-06-02).
  5. **Atomic admin UI fix in `pages/admin/card-import.js`.** Added `'Authorization': \`Bearer ${localStorage.getItem('auth_token')}\`` to the import fetch's headers (one-line addition). **This was the architect's critical pre-brief discovery and the reason Decision 1 routed back to the operator** — gating the import APIs without this matching client fetch fix would have closed P0 #6 but introduced an immediate 401 on every "Import Cards" click, producing a visible UX regression on the only live admin tooling that exercises the gated routes. Shipping the API gate + the client fix in the same atomic PR is what made Decision 1 Option A viable.
  6. **`.cursor/rules/api-routes.mdc` § Rate limiting extended** with the per-class table + verbatim call shape + gate-ordering rules (method check first; auth before any user-keyed limiter; admin-role check goes between auth and rate-limit for the import routes) + identifier-extraction documentation + uniform 429 response shape + fail-closed env-var contract + fail-open Upstash-outage behavior. Doc-writer pass verified the implementer's extension is complete; no further touch-ups needed.
  7. **All six architect decisions ratified at gate 1.** D1 operator-ratified (Option A — gate all three import routes plus the atomic admin UI fix); D2-D6 architect-self-ratified per the precedent established by `cors-tighten` D2-D5 and `fix-vercel-deployment-protection-in-ci` A/B/D (hybrid named-limiter shape; per-class limit values with tuning evidence; two-extractor shape with defensive THROW; uniform 429 message; no new vitest / playwright specs in this convoy).
  8. **Diff: 12 files, +1612 / -23 in the squash.** The 1612-addition figure is dominated by the architect's convoy + brief files (676 + 751 lines) which the squash includes because the architect commit preceded the implementer commit on the same branch. Actual source-file diff is much smaller: `lib/rate-limit.js` +90/-23 (lib refactor); `.cursor/rules/api-routes.mdc` +41 (rule extension); 6 route files +69 total (3× +16 for import routes, 4× +7 for search/avatar/generate/users-search); `pages/admin/card-import.js` +1 (Bearer-header addition).
- **As-shipped metrics (post-merge run 26382185019 + subsequent runs):**
  - `Playwright smoke` — PASS in **59s, 3/3 tests in 3.8s** against the post-rate-limit Vercel preview (home redirects ✓ 431ms / sign-in page renders ✓ 331ms / `/api/health` ✓ 193ms). **Critical cross-validation:** smoke calls `/api/health` once per run (well below the new search class's 60/min ceiling), and the home + sign-in routes don't touch any of the 6 newly-gated endpoints — so the new search limiter does NOT 429 the smoke spec. The cross-validation lineage now accumulates across three convoys: smoke test 2 still passes against post-PR-#15 Layout default-user + post-PR-#19 CORS-tighten + post-PR-#20 rate-limiting — the same 3-test spec has defended the auth surface through three sweeping changes without anyone writing a dedicated test.
  - `forbidden-cors-headers` (from `cors-tighten`) — PASS. The convoy is purely additive of rate-limit gate code; no CORS headers were reintroduced.
  - `forbidden-endpoints` (from `fix-auth-bypass` Brief 3) — PASS. No new `pages/api/test-*.js` or deleted-endpoint shapes reintroduced.
  - `Unit tests (vitest)` — PASS, **21/21 in 27s**. Decision 6 (no new vitest specs) verified at architect time (`rg 'rate-limit|@upstash' test/` returns zero matches; the existing 21 specs don't transitively import `lib/rate-limit.js`, so the lib refactor was strictly safer than the convoy file's stale § Known constraints implied).
  - `Lint` — 128 problems (baseline preserved, no regression). Zero new lint problems from the lib refactor, the 6 route edits, or the admin UI one-liner.
  - `Screenshot diff` — `continue-on-error: true` swallow per `adopt-playwright-smoke` Decision 4 (no baseline committed yet). Triggered on PR #20 because the `paths:` filter `pages/**` matches the 6 route edits under `pages/api/`; same minor false-positive as PR #19, tracked by the queued `tighten-visual-diff-path-filter` follow-up. Not a regression.
  - All other gates (`Schema map up to date`, `Aggregate gate`) — green.
- **Operator action required going forward:** **none.** All Upstash env vars (`KV_REST_API_URL` / `KV_REST_API_TOKEN`) were already auto-provisioned via the Vercel Marketplace integration for Brief 4. No new secrets, no infra changes, no CI gates to enable. The fail-loud-in-prod predicate in `lib/rate-limit.js::init()` is self-defending: if a future deploy unsets either env var, every gated route fails closed on the first call (`throw new Error('[rate-limit] Upstash not configured...')`). If a follow-up tuning need surfaces (search 60/min too tight, generate 5/hour too tight), the fix is a single-line `LIMITER_CONFIG` edit; surface as `tune-search-rate-limit` or `tiered-rate-limits` only if real users 429.
- **Owns:** `role-architect` (pattern + Decision 1 routing) → `role-implementer` (per-route).

### 7. Layout default-prop leaks maintainer email — **RESOLVED 2026-05-24**

- **Resolved by:** `fix-layout-default-user` convoy (PR #15, squash commit `ca302a8`). Brief 1 (pre-squash `ddf8fd2`) shipped the Layout default-null + logged-out branch + vitest lock-in; Brief 2 (pre-squash `8c7d127`, rebased to `0f6bfbb` pre-merge) swept the 7 pages that needed page-level fixes.
- **File:** `components/Layout.js` line 562: `function Layout({ children, user = { email: 'me@randallstillwell.com', role: 'user' }, ... })`.
- **Impact:** Any page that renders Layout without passing a `user` prop displays your real email and impersonates you as the logged-in user.
- **Fix:** Default `user = null` and render a logged-out state branch. Verify every page passes `user` explicitly (the graph shows ~13 pages call `Layout`; audit each).
- **As-shipped:**
  1. `components/Layout.js` default prop changed from hardcoded `{ email: 'me@randallstillwell.com', role: 'user' }` to `null`. `UserProfileDropdown` now branches on `user === null` and renders a `<Link href="/login">Sign in</Link>` CTA in place of the avatar + email + dropdown menu (`NavigationContent`'s `authenticatedNavigation` / `myCollectionNavigation` / `adminNavigation` were already null-safe via existing optional chains; no change there).
  2. **7 pages swept** (Brief 2, 11 `<Layout>` call sites total). `pages/scanner.js` (×1), `pages/decks.js` (×3), `pages/deck-builder.js` (×4), `pages/deck/[id].js` (×3) now pass `user={user}` explicitly. `pages/profile.js` and `pages/settings.js` replaced their leaky `useState({ email: 'me@randallstillwell.com', role: 'admin' })` initializer with `useState(null)` (15 sync `user.*` reads in profile + 1 in settings got null-guards). `pages/card/[id].js` replaced its hardcoded `const user = { email: 'me@…', role: 'user' }` with `const { user } = useAuth()` from `lib/use-auth.js`.
  3. **10 pages already correct** (architect's per-page audit, Decision B in `.convoys/fix-layout-default-user.md`): `dashboard`, `my-cards`, `cards`, `collections`, `collection/[identifier]`, `community/collections`, `admin/card-import`, `admin/card-editor`, `invite/accept`, `invite/decline`. No changes there.
  4. **Test coverage:** `test/components/Layout.test.js` (new) adds 5 regression-lock assertions — no maintainer email when `user` is `null`/omitted; "Sign in" link present when logged out; supplied email renders when supplied; no accidental `Guest` placeholder. Vitest 21/21 green at merge (16 pre-existing auth tests still green).
  5. **New devDeps:** `jsdom@^29` + `@testing-library/react@^16` (test-only). `vitest.config.js` got a 3-line `esbuild` block to parse JSX in `.js` files (per-file `// @vitest-environment jsdom` directive — no global env change).
  6. **Verification at merge:** `rg 'me@randallstillwell.com' pages/` → 0 hits; anonymous `curl /cards` returned HTTP 200 with no maintainer email; lint baseline match (128 problems, unchanged); CI Aggregate gate / Lint / Vitest / Vercel preview / forbidden-endpoints all green. `Playwright smoke` + `Screenshot diff` red but for an unrelated CI-infra reason — see CI infrastructure side-effect note below.
- **Flagged-but-deferred** (deliberately out of scope per the convoy spec):
  1. 4 pages still import `useAuth` from `lib/auth-context.js` (`pages/scanner.js`, `pages/decks.js`, `pages/deck-builder.js`, `pages/deck/[id].js`) — collapsing the three parallel client-side auth surfaces is the queued `single-auth-provider` convoy (P1 #9 in this file), not this one.
  2. `components/MobileNavigation.js` still receives a dead `user` prop (it accepts `{ user, onMenuOpen }` but never reads `user.*` — the bottom-bar items are static). Queued as `cleanup-mobile-nav-dead-props` (or fold into `god-component-split` if that lands first).
  3. `pages/card/[id].js` still imports `useIsAdmin` from `lib/admin-auth.js` — third parallel auth surface; same `single-auth-provider` convoy will collapse it.
- **CI infrastructure side-effect (not part of this convoy).** PR #16 (squash commit `7e97254`) landed alongside as a CI permissions fix, adding scoped `permissions:` blocks to `.github/workflows/preview-smoke.yml` + `.github/workflows/visual-diff.yml`. That fixed the 5-second 403 "Resource not accessible by integration" failure on both workflows but exposed a second issue: with permissions correct, both now reach the actual deployment check and 10-min-timeout against Vercel Deployment Protection's 401 SSO challenge (anonymous GitHub runner GETs the preview URL). New queued convoy `fix-vercel-deployment-protection-in-ci` (`.convoys/fix-vercel-deployment-protection-in-ci.md`) tracks that follow-up.
- **Owns:** `role-implementer`.

### 8. Next.js 15.4.3 — Vercel platform blocks deploys (vulnerable version) — **RESOLVED 2026-05-23**

- **Resolved by:** `bump-next-js` convoy, single-brief PR commit `e57ea17` ("bump: next 15.4.3 -> 16.2.6, ESLint flat config (v9 fallback), typescript devDep"). The Vercel platform gate cleared with the first successful deploy on the same date; every subsequent PR (`fix-auth-bypass`, `drop-public-setup`, `fix-layout-default-user`, the CI permissions fix) has had a green Vercel preview.
- **Discovered:** 2026-05-22 during the bootstrap PR CI run. Vercel build completes successfully (~29s) but the deployment exits with status `Error` and `"Vulnerable version of Next.js detected, please update immediately"`.
- **Files:** `package.json` line 22 (`"next": "^15.4.2"` → locked at `15.4.3`), `package-lock.json`.
- **Impact:** **Vercel will not deploy any branch — including `main` — until Next.js is bumped.** Preview URLs are unavailable, which means `preview-smoke.yml` and `visual-diff.yml` can't fire. The last successful deploy on `main` was 2025-08-01; production may already be running an outdated build.
- **CVE context:** Next.js shipped a middleware auth-bypass advisory (CVE-2025-29927) patched in 15.2.3, plus subsequent advisories. The exact CVE Vercel is flagging on 15.4.3 needs confirmation via `npm audit` and the Next.js security advisory page.
- **Fix:** Bump `next` to the latest secure 15.x (`npm install next@^15.5` and run smoke tests) OR the latest 16.x (`next@^16.2.6` — major bump; review breaking changes in [Next.js 16 release notes](https://nextjs.org/blog/next-16)).
- **As-shipped (Decision A in `.convoys/bump-next-js.md` — leapfrog to 16):**
  1. `next`: `^15.4.2` → `^16.2.6` (resolves to `16.2.6`).
  2. `eslint-config-next`: `15.4.2` → `^16.2.6`. Config migrated from `.eslintrc.json` to `eslint.config.mjs` (eslint-config-next@16 is flat-config-only).
  3. `eslint`: `^8` → `^9.39.4` (Decision D fallback — v10 surfaced Risk R15 empirically because `@typescript-eslint/scope-manager@8.59.4` bundled by `eslint-config-next@16` doesn't implement v10's new `addGlobals` API; v10 adoption deferred to a separate `bump-eslint-10` convoy, upstream-blocked on typescript-eslint).
  4. `typescript`: newly added at `^5.9.3` as a devDep (Decision C — required by the typescript-eslint chain regardless of ESLint major; no project source migration to TS).
  5. `scripts.lint`: `"next lint"` → `"eslint ."` (next lint removed in 16). Lint baseline grew from ~100 to **128 problems** (81 errors, 47 warnings) due to `eslint-plugin-react-hooks@7.1.1` + `@next/eslint-plugin-next@16.2.6` rule additions; CI tolerates this via the `|| true` wrapper in `.github/workflows/ci.yml` per P1 #11.5 (`fix-lint-baseline`).
  6. `next.config.js`: `images.domains` → `images.remotePatterns` (deprecated and removed in 16; preserves Scryfall, Pokémon TCG, Lorcana API hosts for eventual `next/image` adoption).
  7. Verification at merge: `npm install` clean (no ERESOLVE), `npm run build` exit 0 with Turbopack (~1.4s compile, 23 static pages + 47 API routes), first green Vercel deploy on `main` since 2025-08-01.
- **Side-effects (deliberately deferred, not part of this convoy):**
  - `bump-react` (React 18 → 19) — held until 18.x EOL or until a feature needs it.
  - App Router migration — multi-month effort; queued indefinitely.
  - `adopt-vitest` ✅ shipped as `fix-auth-bypass` Brief 5; `adopt-playwright-smoke` partially shipped via the Vercel-bound workflows (CI infra now blocked by `fix-vercel-deployment-protection-in-ci`).
  - `fix-lint-baseline` (P1 #11.5) — **RESOLVED 2026-06-02** (PRs #61–#63); lint baseline **0**; CI lint blocking.
  - `bump-eslint-10` + `bump-typescript-6` — upstream-blocked on typescript-eslint shipping v10-tested releases.
- **Doc drift note:** this resolution was applied as part of the `fix-layout-default-user` post-convoy cleanup (commit reflecting `b7ddd08`'s sibling) — the `bump-next-js` convoy never ran a dedicated doc-writer pass, so this RESOLVED entry was added ~24h after the fix actually shipped.
- **Owns:** `role-architect` (pick target version + assess breaking changes) → `role-implementer` (bump + verify dev/build/start + smoke).
- **Convoy:** `bump-next-js` — ran before `fix-auth-bypass`. **Without this convoy, every L3 gate that depends on a Vercel preview was non-functional.**

## P1 — pre-launch quality bar

### 8. Two SQL clients in parallel (`@neondatabase/serverless` + `@vercel/postgres`) — **RESOLVED 2026-05-26**

- **Resolved by:** `single-sql-client` convoy, squash commit `c403ea4` (PR #30). Parent-owned end-to-end (no architect or implementer subagent dispatched per the convoy file's "Owns" decision; single-file proven-pattern surface, mirroring the `fix-reset-db-script` precedent).
- **Impact (pre-fix):** Two different param-handling APIs, two different transaction stories, two different connection-pool stories. Plus `lib/database.js`'s manual interpolation + `sql.unsafe(query)` was a SQL-injection vector if any caller passed user input through. Architect audit found **no current call site** actually exercised the unsafe shape with user input (the 2 callers pass a numeric `users.id` from a verified JWT), so this was foot-gun removal rather than a live security finding — see `.convoys/single-sql-client.md` § D4 for the audit.
- **As-shipped:**
  1. `lib/database.js` (47 lines) **deleted**. The `DatabaseAdapter` abstraction is gone; no replacement.
  2. **2 callers migrated** (the convoy spec's "~3 files based on graph" estimate was loose; architect grep confirmed exactly 2):
     - `pages/api/auth-utils.js` — `isAdmin(userId)` + `getUserById(userId)` swapped from `db.query(\`SELECT … WHERE id = $1\`, [userId])` to `` await sql`SELECT … WHERE id = ${userId}` `` (byte-equivalent SQL, identical `result.rows[0]` access pattern). Import swapped from `'../../lib/database.js'` to `'@vercel/postgres'`.
     - `test/api/auth-utils.test.js` — dropped the now-unused `vi.mock('../../lib/database.js', ...)` call and the unused `vi` import. The 5 tests (2 `generateToken` + 3 `verifyToken`) are unchanged; they never exercised `isAdmin` / `getUserById` in the first place.
  3. **`@neondatabase/serverless` retained as a runtime dep.** 11 `scripts/*` helpers still import `neon()` directly (`setup-neon-db.js` admin seed, `migrations/2026-05-24-rename-admin-email.js`, `reset-db.js`, plus 8 historical `add-*.js` / `fix-*.js` / `seed-*.js` jobs) — all out of scope per the no-go-zones rule. The dep-purge is tracked as the newly queued `purge-neondatabase-serverless-fully` follow-up (unblocked by PR #32 `migration-tool` — the migration helpers now go through `node-pg-migrate`'s `pg` client, not `@neondatabase/serverless`).
- **As-shipped metrics (PR #30, merged 2026-05-27T03:54:01Z UTC / local 2026-05-26):**
  - Diff: **4 files, +447 / -64.** The 447-addition figure is dominated by `.convoys/single-sql-client.md` (the planning document, committed atomically). Actual source-file diff is small: `pages/api/auth-utils.js` +5 / -7, `test/api/auth-utils.test.js` +1 / -5, `lib/database.js` 0 / -47.
  - `Lint` — **125 problems** (baseline preserved post-`single-auth-provider`; the `lib/database.js` deletion did not change lint count because the file was already lint-clean).
  - `Vitest` — **21/21 pass**.
  - `Playwright smoke` — **3/3 pass** (the smoke spec doesn't exercise `isAdmin` / `getUserById`, but the deployed preview is unaffected by the swap, so the cross-validation lineage continues).
  - `Screenshot diff` — **not triggered** (PR #30's diff is `pages/api/**` + `lib/**` + `test/**` + `.convoys/**`; the post-PR-#26 `!pages/api/**` exclusion correctly held — see PR #26 below).
  - `forbidden-endpoints` + `forbidden-cors-headers` — green.
- **Operator action required going forward:** **none.** No env-var change; no schema change; no infra change.
- **Owns:** `role-architect` (audit + decisions D1-D5 in `.convoys/single-sql-client.md`) — same parent agent that implemented.

### 9. Three parallel client-side auth implementations — **RESOLVED 2026-05-26**

- **Resolved by:** `single-auth-provider` convoy, squash commit `0668b0c` (PR #31). Parent-owned end-to-end (architect + implementer rolled together — mechanical diff once D1's shape-parity decision was made).
- **Files (pre-fix):** `lib/auth-context.js` (`AuthProvider` / `useAuth`), `lib/admin-auth.js` (`AdminProvider` / `useAdmin` / `useIsAdmin`), `lib/use-auth.js` (`useAuth`).
- **Impact (pre-fix):** Pages randomly imported from one of three places. State was duplicated. Logout in one provider didn't necessarily clear the others. Token-verify roundtrips happened up to 3× on `pages/card/[id].js` mount.
- **As-shipped:**
  1. **`lib/auth-context.js` + `lib/admin-auth.js` deleted.** No replacement; `lib/use-auth.js`'s hook-only `useAuth()` is the sole client auth surface.
  2. **Importer inventory was 7 source files, not the ~30 estimated in P1 #9.** The estimate was pre-`fix-layout-default-user` (PR #15, `ca302a8`); that convoy had already migrated most of the tree to `lib/use-auth.js`, so the residual surface was much smaller than the estimate. Architect grep (`rg "from ['\"].*lib/auth-context['\"]" --type js`) returned exactly 6 importers of `auth-context.js` (`pages/_app.js`, `pages/index.js`, `pages/scanner.js`, `pages/decks.js`, `pages/deck/[id].js`, `pages/deck-builder.js`) + 1 importer of `admin-auth.js` (`pages/card/[id].js`).
  3. **`<AuthProvider>` wrapper removed from `pages/_app.js`.** Per D3: `useAuth()` from `lib/use-auth.js` is hook-only, no Provider needed. `<ThemeProvider>` stays. `<AdminProvider>` was never in the tree to begin with (confirmed by reading `_app.js` pre-convoy).
  4. **`useIsAdmin()`'s lone consumer inlined.** `pages/card/[id].js` was the only consumer; replaced with `const isAdmin = user?.role === 'admin'` from the existing `useAuth()` call (D2). Rendering condition at line 524 unchanged byte-for-byte; `adminLoading` kept as a local alias of `authLoading` to keep the diff minimal.
  5. **Verify roundtrip count reduced 3 → 1** on `pages/card/[id].js` mount, and 2 → 1 on every other page-load. Single source of truth for `user` state per hook call site.
  6. **CODEOWNERS sweep:** `.github/CODEOWNERS` lines for the two deleted files removed (per `.convoys/single-auth-provider.md` § Adjacent doc / config edits).
  7. **Doc surface updated atomically:** `AGENTS.md` § 2 + § 3, `.cursor/rules/auth-and-permissions.mdc`, `.cursor/rules/no-go-zones.mdc`, `.cursor/skills/add-page/SKILL.md` all swept to describe the post-convoy single-surface state. (This entry's parent post-convoy doc-writer pass keeps that work consistent across `ship-readiness.md` and AGENTS.md.)
- **As-shipped metrics (PR #31, merged 2026-05-27T03:58:08Z UTC / local 2026-05-26):**
  - Diff: **15 files, +341 / -263.** 2 file deletions (`lib/auth-context.js`, `lib/admin-auth.js`); 13 modifications (7 source pages + `.github/CODEOWNERS` + 5 docs / rules / skills + the new `.convoys/single-auth-provider.md` planning doc).
  - `Lint` — **128 → 125 problems** (3 fewer errors; the deleted files contained 3 unused-import / unused-var lints; no new lint surface introduced). **This is the new lint baseline.**
  - `Vitest` — **21/21 pass**. The 4 test files don't import any of the deleted modules (`grep`-confirmed pre-convoy).
  - `npm run build` — succeeds end-to-end; 26 pages compile (10 dynamic API routes + 16 `pages/**` views including every file modified by the sweep). No "useAuth must be used within an AuthProvider" runtime error during SSR — confirms `<AuthProvider>` removal is safe.
  - `Playwright smoke` — **3/3 pass**.
  - `Screenshot diff` — triggered (PR #31 touches `pages/**` non-API plus `components/**` adjacent surface), `continue-on-error: true` swallow per Decision-4 end state of `adopt-playwright-smoke` (no baseline committed yet).
- **Operator action required going forward:** **none.** No new env vars; no schema change.
- **Spec deviation:** none of substance. Pre-merge estimate of ~30 importers in P1 #9 was loose; actual was 7 (documented above as the as-shipped reality).
- **Owns:** parent (architect + implementer rolled together per the convoy file's "Convoy owner" line).

### 10. No tests

- **Impact:** The first agent-driven refactor of `getUserFromRequest` (P0 #1) is high-blast-radius with no safety net.
- **Fix sequence:**
  1. Install `vitest`. Add `npm run test:run` script. **RESOLVED** by `fix-auth-bypass` Brief 5, commit `1629afb`.
  2. Install `@playwright/test`. Wire up `tests/smoke/app.smoke.spec.ts` (already drafted; needs `playwright.config.ts`). **RESOLVED 2026-05-24** by `adopt-playwright-smoke`, PR #18 squash `7b6f751` — 3/3 smoke tests pass in 2.9s, full workflow 59s, zero secret leaks. See § Queued convoys and `.convoys/adopt-playwright-smoke.md` § As-shipped.
  3. Re-enable the `test:` job in `.github/workflows/ci.yml` (commented out at install time). Next remaining step in this fix sequence.
  4. Add unit tests for `lib/permission-middleware.js`, `lib/slug-utils.js`, `pages/api/auth-utils.js`.
  5. Wire `preview-smoke.yml` to run against the Vercel preview URL. **RESOLVED 2026-05-24** by `fix-vercel-deployment-protection-in-ci` (PR #17, `9a3e077`) + `adopt-playwright-smoke` (PR #18, `7b6f751`).
- **Owns:** `role-architect` (test strategy) → `role-implementer` (initial suite).

### 11. No migration tool — `scripts/add-*.js` graveyard — **RESOLVED 2026-05-26**

- **Resolved by:** `migration-tool` convoy, squash commit `de9f334` (PR #32). Parent-owned end-to-end (no architect or implementer subagent dispatched; the convoy spec pre-ratified each Decision's recommended path, and the implementation surface was a small set of well-bounded file edits — see `.convoys/migration-tool.md` § Subagent / multitask footnote).
- **Files (pre-fix):** 27+ scripts in `scripts/` of the form `add-foo-column.js`, `fix-bar-constraint.js`, `seed-baz.js`. No idempotency tracking, no `schema_migrations` table, no rollback.
- **Impact (pre-fix):** Onboarding a new env required re-running every script in the right order. No way to know what had been run on a given Neon branch. Every new column was at risk of being missed in prod.
- **As-shipped (7 architect decisions, all ratified verbatim from the convoy spec at gate 1):**
  1. **Tool: `node-pg-migrate@^8.0.4`** (D1). JavaScript-native, raw-SQL-friendly via `pgm.sql()`, ESM-clean. Rejected `drizzle-kit` / `prisma migrate` / `kysely` because each would force broader TypeScript surface than `AGENTS.md` Gotcha #9 allows. Brings `pg@^8.21.0` as a peer dep (dev-only).
  2. **Migrations directory: `migrations/` at the repo root** (D2). Separates the new tool-wrapped artifacts from the legacy `scripts/migrations/` placeholder (which still houses `2026-05-24-rename-admin-email.js` and is preserved per no-go-zones). Matches `node-pg-migrate`'s default `--migrations-dir migrations`.
  3. **Tracking table: default `pgmigrations`** (D3) — no name collision in the existing schema.
  4. **Initial backfill: `migrations/1779853647564_initial-schema.js`** (~155 lines). Seven `pgm.sql(\`CREATE TABLE IF NOT EXISTS ...\`)` blocks reproducing `scripts/setup-neon-db.js`'s 7-table DDL verbatim (users / cards / user_cards / collections / collection_cards / decks / deck_cards). Idempotent against fresh AND pre-existing envs (the `CREATE TABLE IF NOT EXISTS` shape is a no-op on existing tables; only the `pgmigrations` row changes).
  5. **`setup-neon-db.js` split** (D5): now (1) validates `ADMIN_INITIAL_PASSWORD` + `POSTGRES_URL`, (2) spawns `npm run migrate up` via `node:child_process.spawn` with `stdio: 'inherit'` and rejects with a wrapped error on non-zero exit, (3) seeds the admin-row INSERT with `ON CONFLICT (email) DO NOTHING`. The seven `CREATE TABLE IF NOT EXISTS` blocks are removed from `setup-neon-db.js` — they live in the migration now.
  6. **CI integration: deferred** (D6) to the newly queued `wire-migrate-into-ci` follow-up convoy. Real work (test DB + secret OR Postgres service container) not in scope; documented as known limitation in `.convoys/migration-tool.md` § R3.
  7. **Down-migration on the initial backfill: hard stub that throws** (D7). Rolling back would drop every user / card / collection / deck row. The stub's error message names the recommended alternative (Neon branch + forward-apply). Future migrations should write their own real `down()`.
  8. **Doc surface updated atomically:** `README.md` (§ Installation + new § "Schema changes (post-`migration-tool` convoy)"), `AGENTS.md` § 3 (new "Schema changes" bullet) + § 4 Gotcha #6 (flipped → RESOLVED), `.cursor/rules/no-go-zones.mdc` (rewritten "Schema changes" rule), `.cursor/rules/db-and-schema.mdc` (§ "Schema source of truth" rewritten), `docs/SCHEMA_MAP.md` (preamble re-scoped). The 27+ historical `scripts/add-*.js` / `fix-*.js` / `seed-*.js` graveyard is preserved per no-go-zones; new schema changes go through `npm run migrate create`.
- **As-shipped metrics (PR #32, merged 2026-05-27T04:01:59Z UTC / local 2026-05-26):**
  - Diff: **10 files, +1230 / -136.** The 1230-addition figure includes `.convoys/migration-tool.md` (~600 lines), `migrations/1779853647564_initial-schema.js` (~155 lines), the doc edits, and `package-lock.json` churn for the `node-pg-migrate` + `pg` install.
  - `Lint` — **125 problems** (baseline preserved post-`single-auth-provider`; the new migration file is lint-clean, no new ignore patterns in `eslint.config.mjs`).
  - `Vitest` — **21/21 pass** in ~1.3s. Vitest doesn't touch the migration surface; the run stayed green.
  - `node --check migrations/1779853647564_initial-schema.js` → exit 0.
  - `node --check scripts/setup-neon-db.js` → exit 0.
  - Module-load + `down()` throw verification: `node -e "import('./migrations/1779853647564_initial-schema.js').then(m => m.down())"` throws the documented `[migration:1779853647564_initial-schema] Refusing to drop the initial schema. ...` message.
  - `npm run migrate -- --help` → returns standard node-pg-migrate help text through the wrapper.
  - `Playwright smoke` — **3/3 pass** (sixth consecutive convoy where the same 3-test smoke spec defends the auth surface through a sweeping change — see § Cross-validation in `.convoys/migration-tool.md`).
- **Operator action required going forward:** **none for the convoy itself.** The migration is idempotent against existing prod schema. No new env vars beyond the already-required `POSTGRES_URL` + `ADMIN_INITIAL_PASSWORD`. **Optional but recommended:** the next deploy that runs `setup-neon-db.js` silently applies the backfill migration (recording it in `pgmigrations`) — no operator action; this is just-in-time chained.
- **Live verification status:** **deferred** per convoy spec — the parent did not have a throwaway Neon branch available. Optional post-merge sequence documented in `.convoys/migration-tool.md` § Operator runbook.
- **Spec deviation:** none. All seven decisions landed verbatim from the spec at gate 1.
- **Owns:** parent (architect + implementer rolled together per the convoy file's § Subagent / multitask footnote).

### 11.5. Codebase has ~100 pre-existing ESLint errors — **RESOLVED 2026-06-02**

- **Resolved by:** `fix-lint-baseline` convoy, PRs #61 (`309cfa2`), #62 (`c32bbd1`), #63 (`81bed51`) — three file-group sweeps (components, pages, lib/config). Post-`bump-next-js` baseline had peaked at **128 problems** (81 errors, 47 warnings); last pre-fix count was **125** after `single-auth-provider`.
- **As-shipped:** `npm run lint` exits **0** with no problems; `.github/workflows/ci.yml` `lint` job runs `npm run lint --if-present` with **no** `|| true` cushion and **no** `continue-on-error` — lint failures block merge.
- **Discovered (historical):** 2026-05-22 during the bootstrap PR. The repo had `"lint": "next lint"` in `package.json` but no `.eslintrc.json` — meaning lint was never run. Bootstrap added the config; lint surfaced ~100+ errors after `bump-next-js` (ESLint 9 flat config + stricter react-hooks rules).
- **Convoy:** `fix-lint-baseline` — multitask fan-out by file group (components → pages → lib/config).
- **Owns:** `role-architect` (group strategy) → `role-implementer` (per-group fan-out).

### 12. Branding mismatch — "TCG Vault" vs. "Deck Hearth"

- **Files:** README, `package.json`, seed data say "TCG Vault" / `admin@tcgvault.com`. `components/Layout.js` lines 596 + 689 render "Deck Hearth" + "DH" logo. The `.env.local` template, `vercel.json`, and Vercel project name should also be audited.
- **Impact:** Confusing for users. Confusing for marketing. Confusing for analytics. Pick one.
- **Fix:** Brand workshop → final name → global replace → update README, package.json `"name"`, every UI string, Vercel project name, email sender, support pages. Schedule a redirect from the old domain.
- **Owns:** `role-ia-architect` (which name? — needs human decision) → `role-implementer`.

## P2 — refactor priorities

### 13. God components (10 files over 500 lines)

| File | Lines | Notes |
| --- | --- | --- |
| `pages/cards.js` | 1499 | `AuthenticatedCards` (886) + `Card3D` (502) live in one file. Split into `pages/cards/index.js` + `components/Card3D.js`. |
| `pages/collection/[identifier].js` | 1044 | `CollectionView` is one mega-component. Extract: header, card-grid, share-modal-wrapper, edit-form. |
| `pages/collections.js` | 989 | Similar structure to collection/[identifier]. Possibly share extracted pieces. |
| `pages/card/[id].js` | 913 | `CardDetail` — split into header, owned-badge, add-to-collection-flow. |
| `pages/deck-builder.js` | 823 | `DeckBuilder` — extract card-search, deck-list, mana-curve panels. |
| `components/CameraScanner.js` | ~45 | **RESOLVED 2026-06-02** — god-component-split slice shipped PRs #67–#72 + view extract. Pre-split ~1,050 lines; now composes `useCameraScanner` + `useScannerIdentification` + `CameraScannerView`. Logic lives in `lib/scanner-card-detection.js`, `lib/scanner-card-identify.js`, `lib/scan-capture-upload.js`, `components/ScanDisambiguationDialog.js`. |
| `pages/admin/card-editor.js` | 778 | Form heavy. Use a `useFormState` pattern + separate the search-results subview. |
| `pages/scanner.js` | ~75 | **RESOLVED 2026-06-02** — god-component-split slice (Briefs 1–3): session/route libs (#74), `useScannerQueue` (#75), `ScannerPageView`. Pre-split ~825 lines. |
| `pages/settings.js` | 669 | One screen per settings section is the usual fix. |
| `pages/profile.js` | 625 | Avatar generation logic alone is ~150 lines — extract `useGeneratedAvatar` hook. |

Each is one convoy of its own. Use the architect role's `slice_dependencies:` to fan out implementers safely.

### 14. Schema-design smells (documented in `docs/SCHEMA_MAP.md`)

- `users` has two avatar columns (`profile_image_url` + `avatar_url`). Reconcile.
- `collections` has two visibility flags (`is_public BOOLEAN` + `visibility VARCHAR`). Reconcile.
- `cards.quantity` + `cards.favorited` are unused (they belong on `user_cards` / `user_favorites`). Drop.
- `user_settings` table duplicates several `users` columns. Reconcile.
- All enum-shaped VARCHARs (`role`, `condition`, `theme`, `game`, `visibility`) should be CHECK-constrained or proper Postgres ENUMs.
- `collections.tags` is `TEXT` (comma-separated). Migrate to `JSONB` or a join table.

### 15. Component coupling warning from graph

`user-code-review-graph` flagged:
- High coupling (44 edges) between `components-handle` and `pages-handle` (largely `Layout`, `CardItem`, `ManaCost` — expected for a shared UI surface).
- High coupling (34 edges) between `lib-admin` and `api-handler` — almost all via `getUserFromRequest`. After P0 #1 is fixed, this number stays high because the auth check is genuinely shared — that's fine.

### 16. Lots of inline SVG and emoji

The `getIcon` registry in `Layout.js` and `MobileNavigation.js` redefines the same SVG paths. Extract to `components/icons/` with named exports. Then audit the codebase for inline SVG that should be a named import. Bonus: lazy-load the larger icon families.

## P3 — UX, IA, design-system

### Role-ia-architect findings

- **URL structure** — solid. `/cards`, `/collections`, `/collection/[slug]`, `/deck-builder`, `/community/collections`. Coherent. One quirk: `/card/[id]` (singular) for detail vs. `/cards` (plural) for index — typical Next.js shape but worth a redirect rule so `/cards/[id]` also resolves.
- **Logged-out homepage** — current `pages/index.js` is 316 lines; needs an editorial pass. What's the value prop in one sentence? Right now it's mostly "we have cards".
- **Onboarding** — signup → profile setup → first collection → scan-or-import card. Currently each step is a separate page. Consider a multi-step wizard at `/onboarding` to keep the new user in flow.
- **Discoverability** — `/community/decks` and `/community/forums` are in the nav but flagged as placeholders. Either ship the MVP for each before launch (forums likely too big) or hide the nav items until they exist.

### Role-ux-reviewer findings

- **Loading states** — most data fetches set `loading: true` then re-render; very few show skeletons. Card grids should use shimmer placeholders; modals should disable submit while in flight.
- **Error states** — error messages bubble to `console.error` and toast nothing. Add a global toast system (e.g. `sonner`) and wire every catch block.
- **Empty states** — `/my-cards` and `/collections` when empty drop to "no cards yet". Replace with first-time CTA: "Scan your first card" or "Browse popular sets".
- **Mobile drawer** — `MobileNavigation` is solid (recent commit `442e906`). One thing: the bottom-bar's active state contrast looks low in light mode; verify against AA.
- **Camera scanner UX** — detection/identify logic split complete (PRs #67–#72); view markup in `CameraScannerView.js`. Remaining polish: theme-token cleanup for overlay hex colors, busier toolbar simplification.

### Role-design-system-auditor findings

- **Two visual languages mixing** — Tailwind classes AND CSS variables on the same elements. This is documented in `.cursor/rules/ui-and-theming.mdc`; the cleanup is to define which property goes where and enforce.
- **Hardcoded hex colors** — grep for `bg-\[#` and `style={{ backgroundColor: '#`. There are still a handful; convert to theme tokens.
- **Logo + brand** — see P1 #12. Then once the name is settled, the "DH" logo + AnimatedFireLogo need to be unified into one brand mark.
- **Modal patterns** — `CollectionSelectionModal`, `ShareModal`, `UploadImageModal` each have their own backdrop + focus-trap implementation. Extract `<Modal>` primitive. Use `headlessui` or `radix-ui`'s Dialog to get focus management for free.
- **Card grid spacing + density** — `pages/cards.js` (the 1499-line monster) does responsive grid math inline. Extract a `<CardGrid>` component that handles density (compact / comfortable / spacious) + sort + filter chrome.

### Role-a11y-auditor findings

- **Focus traps in modals** — none of the modals trap focus. Tab through `ShareModal` and you leave to the background. Critical for keyboard users + screen readers.
- **ESC to close modals** — inconsistent. Some have it, some don't.
- **Skip-to-content** — no `<a href="#main" class="sr-only focus:not-sr-only">`. Add to `_app.js`.
- **Image alts** — card images use `alt={card.name}` (good); avatar images sometimes have empty alts. Audit.
- **Color contrast** — verify the muted text colors (`var(--text-secondary)`) hit AA on both themes. The mobile bottom-bar inactive state is a likely fail.
- **Form errors** — login/signup form errors are visually red but not connected to inputs via `aria-describedby`. Screen readers don't know which field failed.
- **Keyboard ops on non-button elements** — most clickable `<div>`s already have `onKeyDown` but a few don't (audit with `rg "onClick" components pages | rg -v "<button"`).

### Role-doc-writer findings

- **README** — needs a public-facing rewrite. Currently mixes user docs + dev setup + admin credentials. Split into `README.md` (project landing) + `docs/DEVELOPMENT.md` (dev setup) + delete the admin credentials section entirely.
- **`docs/SCHEMA_MAP.md`** — installed at bootstrap (this convoy). Keep it fresh on every schema change.
- **CHANGELOG** — none yet. Adopt Keep-a-Changelog format. Backfill `[0.1.0] — initial private alpha` covering everything to date.
- **`TESTING_GUIDE.md`** — currently the only test doc; rename to `docs/MANUAL_QA.md` once `vitest` + `playwright` land.
- **`docs/API_REFERENCE.md`** — would help. Could be auto-generated by walking `pages/api/**/*.js` and extracting JSDoc; or hand-curated to start.
- **Privacy policy / Terms of service** — required before public launch. Use a template (Termly / Iubenda) and customize.

## Proposed launch sequence

Each phase is one Conductor-created convoy. Don't run more than two in parallel until tests exist.

0. **`bump-next-js`** (P0 #8). One PR. **MUST land first** — Vercel is currently blocking all deployments, which makes every other PR's preview-smoke / visual-diff gate non-functional. Trivial bump; risk is breaking changes if going to 16.x.
1. **`fix-auth-bypass`** (P0 #1, #2, #4, #5, #6 partial). One PR. Highest risk; needs human review.
2. **`drop-public-setup`** (P0 #3, #4). One PR. Trivial; do as a hotfix.
3. **`fix-layout-default-user`** (P0 #7). One PR. Trivial.
3.5. **`fix-lint-baseline`** (P1 #11.5). 2-4 PRs via multitask. **RESOLVED 2026-06-02** — PRs #61–#63; lint **0 problems**; CI lint blocking.
4. **`add-rate-limiting`** (P0 #6 full). One PR. Adds @upstash/ratelimit + applies to listed routes. **RESOLVED 2026-05-24** — PR #20 squash `708ef45`; closes P0 #6 (last open P0), flipping the ship-blocker set to **8/8 RESOLVED**. 5 named limiters (auth/search/upload/generate/import), 6 routes newly gated + the 3 import routes auth-gated atomically with a `pages/admin/card-import.js` Bearer-header fix. Smoke 3/3 green in 3.8s post-merge — confirms the new 60/min search limiter doesn't 429 the smoke spec. See § Queued convoys and P0 #6 above for the full as-shipped block.
5. **`pick-a-name`** (P1 #12). Human decision first, then one or two PRs. **RESOLVED 2026-05-24** — PR #21 squash `9abbab6`; closes P1 #12 (brand-consistency, the inconsistency `AGENTS.md` line 5 had flagged since project setup). Two file-disjoint briefs landed serially (B1 commit `ac8c998` display + comment sweep across 7 files; B2 commit `1c18d21` infrastructure + email migration across 10 modified + 1 new migration script). Five canonical-string D-decisions ratified verbatim at gate-1 (Deck Hearth / `deck-hearth` / `deckhearth` / `admin@deckhearth.com` / full `deckhearth` Redis prefix) plus Risk 4 PRESERVE on `test/lib/permission-middleware.test.js` line 87's negative regression-lock literal. Smoke 3/3 green in 1m4s post-merge — fourth convoy in a row (PR #15 → #19 → #20 → #21) where the same 3-test smoke spec defends the auth surface through a sweeping change. **Operator action required:** run `node scripts/migrations/2026-05-24-rename-admin-email.js` against prod Neon BEFORE the next admin login attempt with the new email (idempotent, UNIQUE-collision-safe). See § Queued convoys for the downstream `rename-repo-and-vercel-project` + `point-domain-at-deckhearth` + `regenerate-brand-assets` follow-ups, and `.convoys/pick-a-name.md` § As-shipped for the full record.
6. **`adopt-vitest`** (P1 #10 step 1). One PR. Enables testing every future change.
7. **`migration-tool`** (P1 #11). One PR. **RESOLVED 2026-05-26** — PR #32 squash `de9f334`; closes P1 #11 (no migration tool). `node-pg-migrate@^8` adopted with the initial schema backfilled to `migrations/1779853647564_initial-schema.js`; `setup-neon-db.js` now owns env-var validation + migration-runner spawn + admin-row seed only. The 27+ historical `scripts/add-*.js` / `fix-*.js` / `seed-*.js` graveyard is preserved per no-go-zones; new schema changes go through `npm run migrate create`. All 7 architect decisions ratified verbatim at gate 1. Smoke 3/3 green post-merge (sixth consecutive convoy in the cross-validation lineage). See § P1 #11 above for the full as-shipped block.
8. **`single-sql-client`** (P1 #8). 2-3 PRs, fanned out via multitask once per-file briefs are written. **RESOLVED 2026-05-26** — PR #30 squash `c403ea4`; closes P1 #8 (two SQL clients in parallel). `lib/database.js` deleted; the 2 callers (`pages/api/auth-utils.js` source + `test/api/auth-utils.test.js` mock cleanup) migrated to `@vercel/postgres` tagged templates. The "2-3 PRs via multitask" estimate was loose — actual surface was a single 4-file PR (architect grep confirmed exactly 2 callers, not the ~3 from graph). `@neondatabase/serverless` retained because 11 `scripts/*` helpers still use `neon()` directly (deferred to the newly queued `purge-neondatabase-serverless-fully`, unblocked by step 7 above). Lint baseline preserved at 125 (the new post-PR-#31 baseline). See § P1 #8 above.
9. **`single-auth-provider`** (P1 #9). 3-5 PRs via multitask. **RESOLVED 2026-05-26** — PR #31 squash `0668b0c`; closes P1 #9 (three parallel client-side auth implementations). `lib/auth-context.js` + `lib/admin-auth.js` deleted; 7 source files swept (`pages/_app.js`, `pages/index.js`, `pages/scanner.js`, `pages/decks.js`, `pages/deck/[id].js`, `pages/deck-builder.js`, `pages/card/[id].js`); `<AuthProvider>` wrapper removed from `_app.js`; `useIsAdmin()`'s lone consumer (`pages/card/[id].js`) inlined as `user?.role === 'admin'`. Verify-roundtrip count reduced 3 → 1 on `card/[id].js` mount, 2 → 1 on every other page-load. Pre-merge importer estimate was ~30; actual was 7 because `fix-layout-default-user` (PR #15) had already migrated most of the tree. The "3-5 PRs via multitask" estimate collapsed to a single atomic PR for the same reason. **Lint baseline improved 128 → 125** (3 fewer errors from deleted unused-import / unused-var lines in the deleted files); this is the new lint baseline. See § P1 #9 above.
10. **`adopt-playwright-smoke`** (P1 #10 step 2). One PR. **RESOLVED 2026-05-24** — PR #18 squash `7b6f751`; smoke 3/3 green in 2.9s, full workflow 59s, zero secret leaks. See § Queued convoys for the full as-shipped block.
11. **`schema-cleanup`** (P2 #14). Multi-PR convoy via multitask.
12. **`god-component-split`** (P2 #13). One convoy per file; fan out via multitask once architect's `slice_dependencies` are written.
13. **`launch-polish`** (P3). UX/IA/a11y/docs convoy.

Total: ~14 convoys to get from current state to public-launch-ready. Estimate 4-8 weeks at one human-in-the-loop reviewer per convoy. Multitask + Cursor 3.2 worktrees compress steps 8-12 substantially.

## Queued convoys

Follow-ups surfaced mid-convoy or mid-PR that didn't fit the original launch sequence but need to land before public traffic. Listed in priority order; not all will be P0/P1 — most are CI / DX / hygiene polish.

- **`rotate-default-admin`** — **RESOLVED 2026-06-13** by PR #141 (`scripts/rotate-admin-password.js`). The convoy chose option B from the architect's three-option menu (close as no-op / build script / build forced-rotation flow): a parameterized one-shot rotation script that's safer than "manually change via app" (audit-trail-preserving via `updated_at`) and lighter than building a first-login forced-rotation flow in the app (that heavier option is the deferred `force-admin-password-reset-flow` convoy). Script reads `POSTGRES_URL` + `ADMIN_NEW_PASSWORD` env vars, validates target row exists + has `role='admin'`, refuses to rotate non-admin rows, verifies the new hash matches the supplied plaintext via `bcrypt.compare` post-update, never echoes the password. AGENTS.md Gotcha #4 documents the rotation workflow. Sibling test users (alice/bob in `scripts/create-test-users.js`) intentionally NOT rotated (dev fixtures, not real auth surfaces).
- **`delete-dead-lorcana-import`** — **RESOLVED 2026-06-02** by PR #59 (`8262fec`). Deleted `pages/api/cards/import-lorcana.js` and `scripts/import-lorcana.js`; no dedicated convoy file (cleanup tracked here only). Entry kept for audit trail.
- **`tighten-visual-diff-path-filter`** — **RESOLVED 2026-05-26** by `tighten-visual-diff-path-filter` convoy, squash commit `ba95462` (PR #26). Single-edit `paths:` filter change in `.github/workflows/visual-diff.yml`: inserted `'!pages/api/**'` immediately after `'pages/**'` (order-sensitive per GitHub Actions' minimatch path-filter semantics — exclusions only fire after a prior include matches). Verified the YAML deserialization order at gate time (`['pages/**', '!pages/api/**', 'components/**', 'styles/**', 'tailwind.config.js', 'postcss.config.js']`). `preview-smoke.yml` left untouched (no `paths:` filter; intentionally fires on every PR). Diff: 2 files, +279 / -0 (1 YAML entry + inline comment block + the planning convoy file). **Post-merge verification still pending** — the only true verification is that the next API-only PR after this merges does NOT trigger `Screenshot diff`. PR #30 (`single-sql-client`, squash `c403ea4`) was the **first API-only PR post-merge** and its CI Checks tab showed `Screenshot diff: not triggered` — empirical confirmation that the `!pages/api/**` exclusion fires correctly. The next-API-only-PR success line was originally specified in the convoy file's § Verification plan as the deferred-to-post-merge gate; this is that confirmation. Entry kept (not removed) to preserve the audit trail. See `.convoys/tighten-visual-diff-path-filter.md` § As-shipped.
- **`purge-weak-creds-from-helpers`** — **RESOLVED 2026-05-26** by `purge-weak-creds-from-helpers` convoy, squash commit `5f2b234` (PR #27). The umbrella is now closed; both remaining halves shipped together. **Multi-convoy history:** (1) `drop-public-setup` Brief 1+2 (`ff80753` + `b63b509`) removed the first `admin123` literal from `scripts/setup-neon-db.js` and set the env-var + fail-loud + no-echo precedent. (2) `pick-a-name` Brief 2 (`9abbab6`) swept the `@tcgvault.com` literals in the three helper paths to `@deckhearth.com` together with the migration script. (3) `fix-reset-db-script` (`3ab9bf8`, PR #25) removed the second `admin123` from `scripts/reset-db.js` and the second `Admin Password:` echo. (4) **This convoy (PR #27)** closes the umbrella by sweeping the last two files: `scripts/create-test-users.js` (alice/bob fixtures, previously hardcoding `bcrypt.hash('alice123', 12)` + `bcrypt.hash('bob123', 12)` and echoing both literals to stdout) and `TESTING_GUIDE.md` (Test Accounts table previously documenting the weak literals). The post-convoy contract: single `TEST_USERS_PASSWORD` env var (intentional simplification per Risk R2 — these are collaboration-flow demo fixtures, not independent identities), fail-loud at the top of `createTestUsers()` BEFORE any DB connection, no password echo anywhere (`✅ Created Alice (alice@deckhearth.com / alice123)` → `✅ Created Alice (alice@deckhearth.com)`), `ON CONFLICT (email) DO NOTHING` preserved. Diff: 3 files, +249 / -22. Lint preserved at 125 (post-PR-#31 baseline); vitest 21/21. ESM-already (this was the first of the three weak-creds-shape convoys to skip the CJS→ESM half because `scripts/create-test-users.js` was already top-level ESM). **Operator caveat:** existing alice/bob rows in already-seeded envs are NOT rotated by re-running the script — `ON CONFLICT` preserves the old hashes; operators must rotate manually via the app or drop those rows and re-seed. Same caveat as the `drop-public-setup` admin-row guidance. **Surfaced out-of-scope follow-up:** `purge-quick-login-from-loginpage` — see new queue entry below. Entry kept (not removed) to preserve the audit trail. See `.convoys/purge-weak-creds-from-helpers.md` § As-shipped.
- **`rename-repo-and-vercel-project`** (priority: P2 polish). Rename the GitHub repo + the Vercel project from `tcg-vault` to `deck-hearth` to match the canonical product brand ratified in `pick-a-name` (squash `9abbab6`, 2026-05-24). Auto-redirects on both GitHub and Vercel make this low-urgency; the surface is a one-line update to local git remotes (`git remote set-url origin git@github.com:<owner>/deck-hearth.git`) + a Vercel project-settings rename + the 8 architect-verified literal-repo references documented in `.convoys/pick-a-name.md` § Full surface inventory § Repo / Vercel project name (out-of-scope) — `README.md` lines 30 + 105, `AGENTS.md` line 1, `.github/workflows/ci.yml` lines 12 + 123, `.github/workflows/visual-diff.yml` line 5, `.agent-context-manifest.yml` source tags. Also re-evaluate the `.agent-context-manifest.yml` `source: "tcg-vault-local"` tag at that point (Risk 5 of `pick-a-name` — renaming the source tag could break the `sync-agent-context` skill's drift tracking; do this convoy with the sync-skill author's input). Surfaced 2026-05-24 as the explicit downstream of `pick-a-name`.
- **`point-domain-at-deckhearth`** (priority: P2 polish; **blocked on domain acquisition**). Once the operator buys `deckhearth.com` (or `.app` / `.gg` / other), wire DNS to the Vercel deployment + claim the domain in Vercel's project settings + update the seed admin email's TLD if the purchased TLD is anything other than `.com` (a one-line REPLACE migration mirroring `scripts/migrations/2026-05-24-rename-admin-email.js`). Surfaced 2026-05-24 as the explicit downstream of `pick-a-name` (the convoy seed § *"DNS / domain — out of scope — separate convoy `point-domain-at-deckhearth` (you don't own a `deckhearth.*` domain yet per operator's pre-convoy statement)"*). Until this lands, the seed admin email `admin@deckhearth.com` is a placeholder STRING used as a unique identifier — auth uses email as identity, not as a mail target, so no working mailbox is required for login to function.
- **`regenerate-brand-assets`** (priority: P2 polish). Regenerate the favicon (`public/favicon.ico`), Open Graph images, and social share cards with the new Deck Hearth identity. Requires a design pass — out-of-scope for any single agent-driven convoy; queue when an asset-design pass is scheduled. Surfaced 2026-05-22 originally in `.convoys/ship-readiness.md` § Role-design-system-auditor findings ("Then once the name is settled, the 'DH' logo + AnimatedFireLogo need to be unified into one brand mark"); reaffirmed 2026-05-24 in `pick-a-name` Out-of-scope queued follow-ups.
- **`convert-reset-db-to-esm`** — **RESOLVED 2026-05-26** by `fix-reset-db-script` (squash `3ab9bf8`, PR #25). `scripts/reset-db.js` now uses ESM top-level imports (`import dotenv`, `import { neon }`, `import bcrypt`) and executes cleanly on Node 22.x. Same fix shape as `setup-neon-db.js` post-`drop-public-setup` B2. As predicted in this entry's prior note, the fold-with-`purge-weak-creds-from-helpers` shape was the right call — both ailments in `scripts/reset-db.js` were fixed atomically with a single 55-line diff. Entry kept (not removed) to preserve the audit trail. See `.convoys/fix-reset-db-script.md` § As-shipped.
- **`lint-against-cjs-in-esm-scripts`** — **RESOLVED 2026-05-26** by `lint-against-cjs-in-esm-scripts` convoy, squash commit `13d6210` (PR #29). Single 7-line flat-config block added to `eslint.config.mjs` after the existing `globalIgnores(...)` call: `{ files: ['scripts/**/*.js'], rules: { 'no-restricted-syntax': ['error', { selector: 'CallExpression[callee.name="require"]', message: '...' }] } }`. The error message points at `.convoys/fix-reset-db-script.md` so a future contributor who trips the rule gets a 1-click path to the exemplar ESM fix shape. Scope decision: `scripts/**` only, NOT all `.js` (matches actual blast radius — every observed bug instance has been in a helper script; the config files `postcss.config.js` / `tailwind.config.js` legitimately use CJS-style exports that the next-config base rules already handle correctly). **Would have caught both `drop-public-setup` Brief 2's pre-fix `scripts/setup-neon-db.js` AND `fix-reset-db-script`'s pre-fix `scripts/reset-db.js` at lint time instead of at first execution** — the exact two motivating bugs from the multi-convoy history. Diff: 2 files, +185 / -0. Lint baseline preserved at 125 (post-PR-#31; zero new false positives in the current tree because both motivating bugs were already fixed). Negative test verified: prepending `const x = require('fs');` to `scripts/reset-db.js` fires the rule at the expected line/column with the documented message; reverting returns to a clean lint. `scripts/migrations/**` was already in `globalIgnores` (from `pick-a-name` Brief 2's migration script); the rule does not fire there. Entry kept (not removed) to preserve the audit trail. See `.convoys/lint-against-cjs-in-esm-scripts.md` § As-shipped.
- **`single-auth-provider`** — **RESOLVED 2026-05-26** by `single-auth-provider` convoy, squash commit `0668b0c` (PR #31; also listed as launch sequence step 9 and § P1 #9 above — both flipped to RESOLVED in the same wave). `lib/auth-context.js` + `lib/admin-auth.js` deleted; 7 source files swept; `<AuthProvider>` wrapper removed from `_app.js`; `useIsAdmin()`'s lone consumer inlined as `user?.role === 'admin'`. Importer inventory was 7, not the ~30 estimated in P1 #9 (most of the tree was already on `lib/use-auth.js` post-`fix-layout-default-user`). Lint improved 128 → 125. Entry kept (not removed) to preserve audit trail. See § P1 #9 + launch sequence step 9 above for the full as-shipped block.
- **`cleanup-mobile-nav-dead-props`** — **RESOLVED 2026-05-26** by `cleanup-mobile-nav-dead-props` convoy, squash commit `171f5af` (PR #28). Two-file, three-line diff: (1) `components/MobileNavigation.js` line 5 — `{ user, onMenuOpen }` → `{ onMenuOpen }`; (2) `components/Layout.js` lines 598-601 — removed the `user={user}` JSX attribute from the only active call site. Audit confirmed `user` was genuinely dead pre-fix (the bottom-bar items are static and don't depend on auth state). `components/Layout.js.backup` left untouched per the `.cursor/rules/no-go-zones.mdc` § "Append-only / historical" rule (its stale `user={user}` call disappears when the `.backup` file is eventually deleted in a separate convoy). Diff: 3 files, +156 / -2 (3 lines source + the planning convoy file). Lint preserved at 125; vitest 21/21 (the `test/components/Layout.test.js` regression-lock assertions for the logged-out Layout branch do not assert on `MobileNavigation`'s prop shape, so the dead-prop removal is invisible to the suite). Pre-existing dead `import { useState } from 'react'` at `MobileNavigation.js` line 3 left untouched per the convoy spec's "single-prop removal" boundary. Did NOT fold into `god-component-split` (P2 #13) — that hasn't landed yet, so this small hygiene convoy shipped first. Entry kept (not removed) to preserve audit trail. See `.convoys/cleanup-mobile-nav-dead-props.md` § As-shipped.
- **`bump-eslint-10`** (priority: P2 hygiene; upstream-blocked). Bump ESLint from v9 to v10 once `typescript-eslint` ships a v10-tested release and `eslint-config-next` bundles it. Surfaced in `.convoys/bump-next-js.md` § Decisions D.
- **`harden-multipart-parser`** (priority: P2 quality). Surfaced 2026-05-24 in `add-rate-limiting` § Risk list. `pages/api/user/avatar.js`'s `parseMultipartFormData` consumes the 5MB multipart body via `req.on('data')` before any response is sent, so an attacker can still exhaust the 5MB body even on a 429 path from the new `checkUploadRateLimit` gate. Real defense requires moving the parse into a separate edge function or using `read-up-to` semantics. Not a release-blocker — the gate-ordering in PR #20 places the limiter BEFORE the method branches that call `parseMultipartFormData`, so when this hardening lands, the gate ordering is already correct. Surface as P1 only if a real abuse incident occurs.
- **`god-function-split` / `refactor-cards-search-sql`** (priority: P2 refactor). Surfaced 2026-05-24 in `add-rate-limiting` § Files explicitly out of scope. `pages/api/cards/search.js` has a 240-line god-function shape with 7+ conditional `SELECT * FROM cards WHERE …` branches; the PR #20 rate-limit gate sits at the top of the handler and leaves the SQL byte-identical. Splitting is its own scope (probably one convoy per branch group with `slice_dependencies:` for safe multitask fan-out). Not security-critical; deferred to the P2 lane.
- **`withAdmin(handler)` wrapper extraction** (priority: P3 polish / DX). Surfaced 2026-05-24 in `add-rate-limiting` Decision 1 + § What did NOT change. `.cursor/rules/auth-and-permissions.mdc` notes *"check `user.role === 'admin'` directly; consider extracting `withAdmin()` if a third call site appears"* — the three `cards/import-*.js` routes are the third+fourth+fifth call sites in the codebase, but PR #20 kept the inline shape for uniformity across the three import routes and for the convoy's atomic-close-P0-#6 goal. A future convoy can extract `withAdmin(handler)` to `lib/permission-middleware.js` (or wherever the architect decides) and sweep all 5 admin-role check sites onto it. Pure refactor; no security delta either way.
- **`seed-visual-baselines-on-linux`** — **RESOLVED 2026-06-02** by PR #58 (`83a358b`). Linux `tests/visual/__screenshots__/home.png` committed; `Screenshot diff` can now compare on UI-touching PRs. Entry kept for audit trail.
- **`adopt-playwright-smoke`** (priority: P1 quality, also listed as launch sequence step 10 / P1 #10 step 2) — **RESOLVED 2026-05-24**.
  - **Resolved by:** squash commit `7b6f751` (PR #18, architect-commit `3ac527e`, implementer-commit `c72d006`). Brief 1 shipped as planned with two small lint-baseline-preserving deviations from the brief's verbatim shape (documented in the convoy file's § As-shipped).
  - **As-shipped surface:** `@playwright/test@^1.60.0` added to `devDependencies`; new `playwright.config.js` at repo root (ESM, two projects partitioned by `testMatch` — `smoke` + `visual`, CI-fail-loud / dev-warn predicate on `VERCEL_AUTOMATION_BYPASS_SECRET` per Decision 2, `snapshotPathTemplate: 'tests/visual/__screenshots__/{arg}{ext}'` aligned with `visual-diff.yml`'s artifact upload path); new `tests/visual/homepage.spec.ts` (1 test, no baseline committed per Decision 4); three new `package.json` scripts (`test:smoke`, `test:visual`, `test:visual:update`); three new `.gitignore` entries (`/playwright-report/`, `/test-results/`, `/.playwright/`). No `eslint.config.mjs` change (Decision 5 + Finding 2 verified clean empirically). No source touched under `pages/**` / `components/**` / `lib/**`.
  - **Implementer deviations** (both behavior-neutral, both lint-baseline-preserving):
    1. Removed Brief 1's `// eslint-disable-next-line no-console` directive on `playwright.config.js`'s `console.warn` branch — the current ESLint config does not flag `console.warn` at all, so the disable directive itself would have regressed lint from 128 → 129 as an "Unused eslint-disable directive" error.
    2. Placed `@playwright/test` first in `devDependencies` for strict alphabetical correctness — the brief's prose was internally inconsistent on neighbors (`@playwright` sorts lexically before `@testing-library/react`).
  - **As-shipped metrics** (from post-merge `Playwright smoke` run 26376162598 on `main`):
    - `Playwright smoke` workflow total runtime: **59 seconds**, exit 0 (was: fast-fail at "playwright not installed" / "no config" before this convoy).
    - `Run smoke tests` step: **3/3 tests pass in 2.9s** against the Vercel preview with `x-vercel-protection-bypass` header applied — `home redirects or renders without 5xx` ✓ 683ms / `sign-in page renders` ✓ 459ms / `public health endpoint responds` ✓ 571ms.
    - `Screenshot diff` workflow: **not triggered on PR #18 itself** because its `paths:` filter excludes test-infra-only changes; first real trigger fires on the next PR touching `pages/**` / `components/**` / `styles/**` / `tailwind.config.js` / `postcss.config.js`. At that point the documented Decision-4 end state runs live (test fails on missing baseline → `continue-on-error: true` swallows → comment-on-PR step posts run link with empty artifacts).
    - Bypass secret leak check: **0 matches** in the raw workflow log. GitHub Actions auto-masks registered secrets; our Decision-2 branches name the env var but never interpolate the value into any string.
  - **Cross-validation finding** (not a planned AC; surfaced organically from CI green): smoke test 2 (`'sign-in page renders'`) asserts `await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible()` against `/login`, which only passes because `components/Layout.js` renders the `<Link href="/login">Sign in</Link>` CTA on the logged-out branch that PR #15 (`fix-layout-default-user`, `ca302a8`) introduced. P0 #7's resolved state is now defended by a live CI signal — if a future PR reverts to a hardcoded default user or breaks the CTA wording, smoke fails the PR (in addition to the 5 vitest assertions in `test/components/Layout.test.js`).
  - **Operator action required going forward:** none for smoke. `seed-visual-baselines-on-linux` **RESOLVED** PR #58 — `Screenshot diff` now has a Linux baseline for homepage.
  - **Flagged-but-deferred** (deliberately out of scope per the convoy file, restated here for the audit trail):
    1. ~~`seed-visual-baselines-on-linux`~~ — **RESOLVED** PR #58.
    2. `adopt-test-smoke-local` (possible follow-up) — a `test:smoke:local` wrapper that auto-boots `next dev`. Explicitly rejected by Decision 6; queue only if dev friction proves out.
    3. Deeper E2E coverage beyond the 3 existing smoke checks — per-feature work in feature convoys, not a test-infra concern.
  - **Owns:** `role-architect` (3 of 6 decisions self-ratified — D2 CI predicate, D3 two-project shape, D5 no-eslint-change; 3 of 6 operator-ratified — D1 keep `.ts`, D4 defer baselines, D6 simple scripts) → `role-implementer` (Brief 1, plus the two deviations above).
- **`fix-vercel-deployment-protection-in-ci`** (priority: P2 CI infra) — **RESOLVED 2026-05-24**.
  - **Resolved by:** squash commit `9a3e077` (PR #17), comprising three commits, not one. Operator prereq seeded 2026-05-24T20:03:31Z (`gh secret set VERCEL_AUTOMATION_BYPASS_SECRET`; confirmed via `gh secret list`); the implementer dispatch waited on that visibility per the convoy file's "Operator action required" gate.
  - **Three-commit reality** (Brief 1 + two scope expansions found during CI validation):
    1. `365e9f0` Brief 1 — bypass plumbing per spec. Both `.github/workflows/preview-smoke.yml` and `.github/workflows/visual-diff.yml` got the same shape change: `wait-for-vercel-preview@v1.3.2`'s `path:` input now carries `/?x-vercel-protection-bypass=${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}&x-vercel-set-bypass-cookie=true` (Decision A's original cookie-variant shape — later corrected in commit 3); `max_timeout: 600 → 120` (Decision B); the `gate:` job's Decide step short-circuits on `github.event.pull_request.head.repo.fork == true` with a `::notice::` annotation, before the existing PR-body skip directive runs (Decision D); and the Playwright/screenshot step exports `VERCEL_AUTOMATION_BYPASS_SECRET` as `env:` for forward-compat with `adopt-playwright-smoke`.
    2. `b6f8688` shell-injection hardening (latent pre-existing bug surfaced by PR #17's own CI validation). Decision D's gate step inlined `${{ github.event.pull_request.body }}` directly into bash, which broke when the PR body contained shell metacharacters like `(` or backticks. PR #17's own description bit this with `"unexpected token \`('"` because of phrasing like *"(was: 10-minute timeout)"*. Fix is the standard GitHub Actions hardening pattern (their official "Security hardening" guide flags inline `${{ }}` in shell as both a syntax-error risk and a shell-injection vector): route the body and the fork flag through the step's `env:` block as `PR_BODY` and `PR_IS_FORK`, then quote them as `"$PR_BODY"` / `"$PR_IS_FORK"` in the shell condition. Same change in both workflows; ~9 LOC each. Documented in commit message as technically beyond Brief 1's scope but bundled into the convoy because the bug actively blocked Brief 1's success criterion from being validated.
    3. `043a6ee` drop `&x-vercel-set-bypass-cookie=true` from the wait-action `path:` — corrects Decision A's exact shape. With the cookie variant, Vercel responds **307 + Set-Cookie**, and axios in Node has no cookie jar — it follows the redirect to the bare URL without the cookie, which then 401s. Empirically confirmed by operator's local curl: bare `?x-vercel-protection-bypass=X` → HTTP/2 200, while `?x-vercel-protection-bypass=X&x-vercel-set-bypass-cookie=true` → HTTP/2 307 (the broken path). For a one-shot healthcheck the per-request bypass query is enough. The cookie variant stays reserved for the future Playwright config (`adopt-playwright-smoke`) where a real browser cookie jar exists. An inline comment in `preview-smoke.yml` explains this so the next agent doesn't accidentally re-add the cookie param.
  - **As-shipped metrics** (from PR #17's CI run, post-validation):
    - `Wait for Vercel Preview deployment` step elapsed: **194 milliseconds** (was: 10-minute timeout before this convoy).
    - `Playwright smoke` workflow total runtime: **59 seconds** (was: 10+ minutes).
    - Step breakdown: `Wait for Vercel Preview deployment` → success in 194ms; `npm ci`, `setup-node`, `playwright install` → success; `Run smoke tests` → **failure** (expected — see next bullet).
    - `Screenshot diff` workflow: not triggered on PR #17 itself because its path filter excludes workflow-only changes; will fire on the next PR touching `pages/**` / `components/**` / `styles/**` / Tailwind/PostCSS config.
  - **Documented expected red**: `Playwright smoke` now reaches `npx playwright test` and fast-fails because `playwright.config.js` doesn't exist in the tree yet. That is `adopt-playwright-smoke`'s scope (P1 #10 step 2 / launch sequence step 10), not this convoy's. Per the convoy file's Test plan § and Brief 1 acceptance criterion #1, a real downstream failure with the wait-action reaching `Received success status code` first counts as success for this convoy — the failure mode shifted from "401 timeout in the wait step" to "playwright not installed", which is precisely the target state.
  - **Operator-rotation caveat** (R6 in the convoy file). The Vercel bypass token does not auto-expire. If/when it's rotated from the Vercel dashboard, the operator must re-seed the GitHub secret via `gh secret set VERCEL_AUTOMATION_BYPASS_SECRET --body "<new value>"`. Same human-responsibility pattern as `JWT_SECRET` rotation; not preventable from workflow YAML. No automation here.
  - **Flagged-but-deferred** (from the convoy file's "Anything flagged but not acted on" section, unchanged at merge):
    1. `replace-wait-for-vercel-preview` — the wait-action's last release was Mar 2024; could be replaced with a few lines of `gh api` + `curl`-loop. Out of scope for this convoy; queue if the action ages out further or gets a security advisory.
    2. `adopt-playwright-smoke` — owns the actual `playwright.config.js`, `tests/smoke/`, and `@playwright/test` dep. The bypass plumbing here is forward-compat for that convoy (env var available on the smoke step). Listed in P1 #10 step 2 / launch sequence step 10 above.
    3. `Screenshot diff` baseline authoring — orthogonal scope; the visual-diff workflow has nothing to compare against on its first real run.
  - **Owns:** `role-architect` (3 Decisions ratified — A query-param, B 120s timeout, D fork-PR skip) → `role-implementer` (Brief 1) + two scope-expansion commits.

- **`purge-quick-login-from-loginpage`** — **RESOLVED 2026-05-29** by PR #56 (`e0218e4`). Quick Login removed from `pages/login.js`. See `.convoys/purge-quick-login-from-loginpage.md` § As-shipped.
- **`purge-neondatabase-serverless-fully`** — **RESOLVED 2026-06-02** by PR #57 (`5115683`). `@neondatabase/serverless` removed from `package.json`; operational scripts use `@vercel/postgres`. Historical `scripts/add-*` / `fix-*` / `seed-*` graveyard unchanged per no-go-zones. Entry kept for audit trail.
- **`wire-migrate-into-ci`** (priority: P2 CI infra). Surfaced 2026-05-26 by `migration-tool` (PR #32) — D6 deferral. Add a CI job that runs `npm run migrate up` against a test DB (either a dedicated Neon branch + `MIGRATE_TEST_DATABASE_URL` secret with branch-reset logic, or a Postgres service container with a ~30s container-start tax). Catches syntactically-invalid migrations + most logical errors at PR time. Currently, the first signal that a new migration is broken is the developer's local `npm run migrate up` against their dev branch (or post-deploy on Vercel). Documented in `.convoys/migration-tool.md` § R3.
- **`reconcile-historical-add-scripts`** (priority: P1 quality — needed for fresh-env onboarding). Surfaced 2026-05-26 by `migration-tool` (PR #32). Fold the effects of the 27 historical `scripts/add-*.js` / `fix-*.js` / `seed-*.js` jobs into the migration history so a brand-new Neon branch can be onboarded by `npm install` → `npm run setup-db` alone (without manually replaying the historical scripts). Multi-PR; ideally one migration per logical change, generated by reading the scripts' SQL and re-shaping into idempotent `pgm.sql(...)` blocks (with `IF NOT EXISTS` / `IF EXISTS` guards so re-application is safe). Documented in `.convoys/migration-tool.md` § R1.
- **`retire-graveyard-scripts-after-audit`** (priority: P3 polish; **blocked on `reconcile-historical-add-scripts`**). Surfaced 2026-05-26 by `migration-tool` (PR #32). Once the migration history captures all historical effects, the legacy `scripts/add-*.js` / `fix-*.js` / `seed-*.js` files can be deleted (or moved to `scripts/historical/`). They remain no-go-zones until that cleanup convoy lands. Documented in `.convoys/migration-tool.md` § Follow-ups.
- **`audit-node-pg-migrate-transitive-deps`** (priority: P3 hygiene). Surfaced 2026-05-26 by `migration-tool` (PR #32) — R5 in the convoy file. `npm audit` reports 11 vulnerabilities (6 moderate, 5 high) coming from `node-pg-migrate@8.0.4`'s `glob@~11.1.0` + `yargs@~17.7.0` transitive deps (older `brace-expansion`, `minimatch`, `picomatch` versions with known advisories). All in dev-only paths; the migration tool runs in scripts/CI, never in the deployed Next.js bundle, and the affected APIs (glob's shell-injection CLI; brace-expansion's ReDoS) are not exercised by node-pg-migrate's call sites. Surface only if a security audit specifically flags this surface, or if `node-pg-migrate` ships a v9 that updates the transitive tree.
- **`add-migration-template`** (priority: P3 DX). Surfaced 2026-05-26 by `migration-tool` (PR #32). Add a custom template via `--template-file-name` so generated migrations include the project's preferred docstring shape + a reminder about `docs/SCHEMA_MAP.md` updates. Surface if migration authoring proves inconsistent.

### Scanner audit portfolio (2026-05-27)

Six convoys authored from the scanner audit portfolio plan. Dependency order:
`secure-scanner-gemini-key` → `server-side-scan-pipeline` → (`add-real-ocr-layer` ∥
`redesign-scanner-flow`); `rename-collections-vocabulary` and
`scanner-correctness-polish` parallel after #1.

- **`secure-scanner-gemini-key`** — **RESOLVED 2026-05-27** — PR #34 (`8c58990`). Client key leak closed; `forbidden-client-side-llm-keys` CI gate. Convoy: `.convoys/secure-scanner-gemini-key.md`.
- **`server-side-scan-pipeline`** — **RESOLVED 2026-05-27** — PR #35 (`e81dd49`). Server-owned identify, `card_submissions`, disambiguation. Convoy: `.convoys/server-side-scan-pipeline.md`.
- **`add-real-ocr-layer`** — **RESOLVED 2026-05-27** — PR #38 (`d798e28`) + polish PR #39. Layer-1 Tesseract + `pg_trgm`. Convoy: `.convoys/add-real-ocr-layer.md`.
- **`redesign-scanner-flow`** — **RESOLVED 2026-05-27** — PRs #42 (Brief 1), #43 (Brief 2), #44 (Brief 3). Post-PR audit `audit-redesign-scanner-flow-44` posted to PR #44; outcome comment-only. See `.convoys/redesign-scanner-flow/audit-redesign-scanner-flow-44.md`. Follow-ups: `scanner-redesign-a11y-fixes` **RESOLVED** PR #45; `scanner-user-cards-quantity-guard` **RESOLVED** PR #46; `test-scanner-redesign-surfaces` **RESOLVED** PR #47.
- **`god-component-split` / `CameraScanner.js` slice** — **RESOLVED 2026-06-02** — PRs #67–#72 (Briefs 1–5) + view extract (`CameraScannerView.js`). Pre-split ~1,050 lines → ~45-line composer + presentational view. Remaining `god-component-split` targets: `pages/cards.js`, `pages/scanner.js`, etc. (see § P2 #13 table).
- **`rename-collections-vocabulary`** — **RESOLVED 2026-05-29** — PR #54 + follow-up PR #55. Convoy: `.convoys/rename-collections-vocabulary.md`.
- **`scanner-correctness-polish`** — **RESOLVED 2026-05-27** — PR #41 (`55af7e3`). Convoy: `.convoys/scanner-correctness-polish.md`.
- **`schema-cleanup-from-scanner-audit`** (priority: P2 schema; **deferred** —
  NOT scanner-specific). Separate convoy when ready; surfaced by the scanner
  audit but applies globally:
  1. **`cards.quantity` + `cards.favorited`** on the global catalog — belong on
     `user_cards` / `user_favorites`; drop from `cards`.
  2. **`collections.is_public` vs `visibility`** — dual visibility flags;
     reconcile to one mechanism (see also P2 §14 in this file).
  3. **`is_system_collection` vs `user_cards` unification** — ownership model
     smell; IA + schema convoy, not a scanner deliverable.
  Do not fold into the six scanner convoys above; queue as its own architect-led
  migration convoy after the scan pipeline stabilizes.

### Catalog freshness (deferred — post-scanner)

- **`catalog-sync-vercel-cron`** — **RESOLVED 2026-05-29** — PRs #48–#52 (weekly Vercel Cron, shared import libs, admin trigger, submission auto-link, Pokémon data source switch). Convoy: `.convoys/catalog-sync-vercel-cron.md`.

### Design-system redesign portfolio — Liquid Glass (2026-06-03)

Operator-requested epic to migrate the UI from the current "warm panel +
side-highlight + heavy gradient" visual language to a **Liquid Glass**
aesthetic that retains Deck Hearth's fireplace warmth as accent /
gradient / motion (not as panel fill). Umbrella convoy authored
2026-06-03; all 8 sub-convoys seeded as `status: open` awaiting
role-conductor refinement when picked up. This is **not a launch
blocker** — the 8 P0 ship-blockers are all RESOLVED — but it
dramatically raises the launch-day quality bar.

**Umbrella convoy:** `.convoys/liquid-glass-redesign.md` (the deep
dive — vision, hard scoping rules, dependency graph, risk register,
operator decision points).

**Dependency-ordered sub-convoys:**

1. **`liquid-glass-design-tokens`** (foundation, no UI change) —
   `.convoys/liquid-glass-design-tokens.md`. Adds glass surface / blur
   / rim-light / elevation tokens + `docs/DESIGN_TOKENS.md`. Strict
   blocker for all subsequent sub-convoys.
2. **`liquid-glass-modal-and-surface-primitive`** — `.convoys/
   liquid-glass-modal-and-surface-primitive.md`. Extracts
   `<GlassSurface>` + `<Modal>` primitives + sweeps all ~15 modals.
   Closes the ship-readiness "Modal patterns" + "Focus traps" findings.
   **This is where modals start blurring the page behind them — the
   operator's core ask.**
3. **`liquid-glass-form-primitives`** — `.convoys/
   liquid-glass-form-primitives.md`. `<Button>`, `<Input>`,
   `<SearchBar>` primitives + migration sweep. Closes the
   ship-readiness `aria-describedby` finding via `<Input>`'s error
   wiring.
4. **`liquid-glass-layout-shell`** — `.convoys/
   liquid-glass-layout-shell.md`. Layout sidebar + header + mobile
   bottom-bar onto glass. Highest-blast-radius PR in the portfolio.
   Closes the ship-readiness bottom-bar contrast finding.
5. **`liquid-glass-card-surfaces`** — `.convoys/
   liquid-glass-card-surfaces.md`. `CardItem`, `CardDetailView`,
   `Card3D`, rarity-glow reconciliation. Per-card `backdrop-filter`
   forbidden (perf budget).
6. **`liquid-glass-public-and-auth`** — `.convoys/
   liquid-glass-public-and-auth.md`. Landing editorial pass + auth
   pages + public collection/deck views. First-impression delivery.
7. **`motion-system-pass`** — `.convoys/motion-system-pass.md`.
   Consolidates 12+ ad-hoc keyframes into a 4-tier motion taxonomy
   + reduced-motion enforcement + per-page budget. Parallel-safe with
   #2–#6.
8. **`cleanup-legacy-design-css`** — `.convoys/
   cleanup-legacy-design-css.md`. Strict-deletion convoy: removes
   `gradient-text-blue/purple/pink`, `glow-blue/purple/pink`,
   `accent-blue/purple/pink` aliases, hex sweep, CI grep gates to
   prevent regression. Ships **last**.

**Multitask plan** (from the umbrella's dependency graph):

- After #1 merges: `/multitask` #2, #3, #7 (disjoint files).
- Inside #2: multitask 4 modal-cluster briefs after Brief 1 lands the
  primitive.
- Inside #3: multitask 2 consumer-cluster briefs after Brief 1 lands
  the primitives.
- After #4 + #5 merge: `/multitask` per-page briefs in #6 (file-disjoint
  by route).

**Per-sub-convoy gates:** every sub-convoy fires `preview-smoke.yml` +
`visual-diff.yml` + `lint` + `test:` (vitest); per-PR post-merge
re-seeds Linux visual baselines via the `seed-visual-baselines-on-linux`
Docker workflow documented in `AGENTS.md` § 6.

**Operator decisions tabled for the architect at sub-convoy #1's
gate-1** (umbrella § Open questions for the operator):

1. Glass tint strength (Apple-leaning vs Linear-leaning; default
   Apple-leaning).
2. Light-theme glass base (warm white vs cool white; default warm).
3. Dark-theme glass base (warm black vs cool black; default warm).
4. Hover ember-rim intensity (subtle / pronounced).
5. Drop `fire-glow-bg` page-background animation? (default: drop;
   retain `ember-float` on landing only.)
6. Sequencing under launch pressure: if shipping before the full epic
   completes, the MVP redesign is #1 → #2 → #4 (modals + Layout);
   then post-launch #3, #5, #6, #7, #8.

**Status snapshot** — full-portfolio drive-through 2026-06-03.
Operator-approved sweep landed the foundation + primitive kit + the
two highest-leverage surfaces (Layout shell + Modal + Form primitive
adoption on auth pages) + the discipline gates that lock in the new
design system. Vitest jumped 84 → 104 (+20 new primitive tests); lint
0 errors throughout; visual-diff baselines must re-seed in Docker
per ` AGENTS.md` § 6 before subsequent UI-touching PRs land:

| # | Slug | Status |
| --- | --- | --- |
| — | `liquid-glass-redesign` (umbrella) | open — drives the portfolio |
| 1 | `liquid-glass-design-tokens` | **MERGED 2026-06-03** — 29 CSS vars + `docs/DESIGN_TOKENS.md` + `AGENTS.md § Visual language` |
| 2 | `liquid-glass-modal-and-surface-primitive` | **Brief 1 MERGED 2026-06-03** — `<GlassSurface>` + `<Modal>` + `useFocusTrap` + 10 tests; 4 reference modal migrations (ShareModal, CollectionDeleteModal, CollectionsCreateModal, CardDetailQuantityModal). Brief 2 (11 remaining modals: CollectionSelectionModal, CollectionsEditModal, CollectionsSuccessModal, CollectionEditModal, CardDetailDeckModal, ScanDisambiguationDialog, UploadImageModal, OCRSettings, ScannerPageView inline, pages/decks.js inline, plus any newcomers) queued — CI gate `forbidden-modal-shell-without-primitive` grandfathers these 9 files |
| 3 | `liquid-glass-form-primitives` | **Brief 1 MERGED 2026-06-03** — `<Button>` + `<Input>` + `<SearchBar>` + 10 tests; login.js + signup.js migrated (2 buttons + 7 inputs total). Brief 2 (profile/settings + deck-builder + scanner search + card-editor admin + collection-cluster modal-form bodies) queued |
| 4 | `liquid-glass-layout-shell` | **MERGED 2026-06-03** — 6 shell surfaces glass-migrated (desktop sidebar, mobile drawer, mobile overlay scrim, search header strip, UserProfileDropdown popover, MobileNavigation bottom bar). Layout regression-lock 5/5 preserved. |
| 5 | `liquid-glass-card-surfaces` | **architecture ratified 2026-06-03; implementation queued** — pixel-sensitive (rarity-glow reconciliation) so wants a dedicated visual-diff baseline re-seed PR. Pre-blocked on a `fix-card3d-state` convoy (Card3D has pre-existing state-management bug). |
| 6 | `liquid-glass-public-and-auth` | **architecture ratified 2026-06-03; partial impl shipped via #3** (login + signup form primitives). Remaining: landing page editorial + public collection/deck views + login/signup outer-wrapper sweep |
| 7 | `motion-system-pass` | **MERGED 2026-06-03** — 8 motion tokens (5 durations + 3 easings) added; `prefers-reduced-motion` sweep upgraded from narrow to site-wide (universal selector w/ `.motion-essential` opt-in escape); `docs/MOTION_SYSTEM.md` authored |
| 8 | `cleanup-legacy-design-css` | **Brief 1 MERGED 2026-06-03** — 2 new CI gates (`forbidden-modal-shell-without-primitive` blocking; `forbidden-deprecated-color-aliases` warn-only audit baseline); `.cursor/rules/ui-and-theming.mdc` documents the primitive kit + canonical reference modals. Brief 2 (actual deletion of legacy aliases + utility classes + `fire-glow-bg` page background) queued for AFTER #2 Brief 2, #3 Brief 2, #5 Brief 1, #6 Brief 1 land. |

**Vitest baseline after portfolio drive-through:** 104 passing
(was 84 pre-portfolio). +10 from `test/components/Modal.test.js`; +10
from `test/components/ui-primitives.test.js`. The 5 Layout regression-lock
assertions (logged-out CTA, no maintainer-email default, "Sign in" link
present, supplied email renders, no "Guest" placeholder) all still
pass — every Layout edit preserved the documented contract.

**What still needs human action before this lands in production:**

1. Squash + push the 8 PR-equivalent stacks (one per sub-convoy that
   shipped commits): #1, #2-Brief-1, #3-Brief-1, #4, #7, #8-Brief-1,
   plus the architecture-ratified #5 and #6 (no impl commits — just
   convoy + roadmap doc edits).
2. Re-seed Linux visual-diff baselines via the Docker workflow
   (AGENTS.md § 6) after each UI-touching PR merges: #2 (modal
   reference migrations), #3 (login + signup form re-render), #4
   (sidebar + header + drawer glass), and #7 (the universal motion
   sweep changes every transition's *behavior under reduced motion*,
   not its default render — likely a no-op for the baseline image,
   but verify).
3. Verify `preview-smoke.yml` passes against each preview deployment
   (the auth + scanner smoke specs touch login / signup / scanner —
   #3 + #4 most likely to surface a regression).
4. Operator-promote each merged-to-main commit to Vercel production
   via the Vercel dashboard (or auto-promote if the project is
   wired that way).

**What still needs follow-up implementer turns to ship full polish:**

- #2 Brief 2 — 11 remaining modal migrations (mechanical pattern
  copy from the 4 reference modals).
- #3 Brief 2 — profile + settings + deck-builder + scanner-search +
  card-editor admin form sweeps.
- #5 Brief 1 — card surface migration (gated on `fix-card3d-state`
  convoy + a dedicated baseline re-seed).
- #6 Brief 1 — landing page editorial + public view glass.
- #8 Brief 2 — actual legacy CSS deletion + CI gate graduation
  WARN → FAIL.

All five are documented inside their respective convoy files with
specific file lists and decision rationale. None is launch-blocking
— the user-visible promise of the epic ("modern fireplace
aesthetic; modals blur the page behind them; reusable components")
is delivered TODAY by the merged work.

### Finish-portfolio sweep (2026-06-03 follow-up)

After the operator promoted PR #95 to production, the follow-up
`finish-liquid-glass-design` PR closed out the remaining briefs in
a single push. **All 8 sub-convoys are now MERGED to main** (only
the deferred Card3D state-management scope remains queued as its
own convoy, blocking #5 Brief 1's pixel-level rim migration).

| # | Slug | Final status |
| --- | --- | --- |
| 2 | `liquid-glass-modal-and-surface-primitive` | **Brief 2 MERGED** — every legacy `fixed inset-0 bg-black bg-opacity-` shell across `pages/` + `components/` migrated to `<Modal>`; CI grandfather list emptied to zero entries; the grep gate is now strict (no-allow-list) and bug-detects any reintroduction. Modals fully consolidated. |
| 3 | `liquid-glass-form-primitives` | **Brief 2 MERGED** — `<Button>` + `<SearchBar>` adopted by dashboard, my-cards, community/collections, and CollectionsPageView; the remaining native button consumers in card-grid / per-row toolbars are intentionally left native (per-row tiny icon buttons whose styling doesn't match `<Button>` variants). |
| 5 | `liquid-glass-card-surfaces` | **scope-revised + MERGED** — the planned migration was descoped after discovering `components/Card3D.js` was **dead code** (never imported from `pages/**` or `components/**`; only referenced in convoy docs). File deleted (-505 LOC). The actual card-grid component (`components/CardItem.js`) was intentionally left untouched in this sweep to preserve the per-rarity glow tuning that the visual-diff baseline locked in; a future implementer turn can apply rim-light tokens with a dedicated baseline re-seed. |
| 6 | `liquid-glass-public-and-auth` | **MERGED** — `pages/index.js` landing editorial fully glass-migrated: three feature cards + featured-list cards now use `<GlassSurface tint="mid" rim="subtle" elevation="ambient">`, top nav got the `--glass-surface-mid` treatment matching Layout's sidebar, and all 6 CTA buttons are now `<Button variant="primary"\|"secondary">`. `pages/invite/{accept,decline}.js` outcome panels wrapped in `<GlassSurface>` + all 8 buttons migrated to `<Button>`. |
| 8 | `cleanup-legacy-design-css` | **Brief 2 MERGED** — every consumer of `gradient-bg-purple` (13 occurrences across 8 files) swept to `gradient-bg-ember`; the now-dead CSS class definitions for `.gradient-text-blue`, `.gradient-text-purple`, and the three `[data-theme="dark"] .glow-{blue,purple,pink}` selectors deleted from `styles/globals.css`; the `forbidden-deprecated-color-aliases` CI gate graduated from WARN to **FAIL** with all 9 patterns blocking. |

**Vitest after the sweep:** 104 passing (unchanged — primitive migrations don't add new unit tests; integration coverage is via Playwright smoke + visual-diff). Lint: 0 errors, 2 pre-existing warnings (unrelated `CardEditorForm.js` + `CollectionsPageView.js` carry-overs that were noted in PR #95).

**CI graduation gates now blocking:**

- `forbidden-modal-shell-without-primitive` — zero allow-list; any new `fixed inset-0 bg-black bg-opacity-` shell fails the build.
- `forbidden-deprecated-color-aliases` — graduated WARN → FAIL; any new use of the 9 pre-Deck-Hearth alias classes fails the build.

**Queued for a future convoy** (no impact on the current ship-readiness state):

- **`fix-card3d-state` is no longer needed** — Card3D was dead code and is now deleted. The card-grid implementer turn that the original sub-convoy #5 envisioned can proceed against `components/CardItem.js` directly when an operator wants the rim-light polish on cards, with its own visual-diff baseline re-seed.

## Self-analytics

After each convoy, `scripts/log-convoy-event.sh` emits a record to `.convoys/.metrics.jsonl` (gitignored). After 3-5 convoys, run the upstream `agent-pipeline/analytics/` aggregator to see where token spend goes — that data feeds whether to add or remove rules.

## How to start

Per `.cursor/agents/role-conductor.md`, start the next convoy with:

> *"Run role-conductor: start a new convoy `fix-auth-bypass` to address P0 #1, #2, #4, #5, #6 partial in `.convoys/ship-readiness.md`. Success = `getUserFromRequest` returns null for missing tokens; no API route accepts unauthenticated requests; CI green."*

The Conductor will set classification, skip flags, and hand off to subsequent roles.
