--- name: cors-tighten classification: convoy success_metric: | No `pages/api/**/*.js` handler ships an `Access-Control-Allow-Origin: *` header (or any other wildcard CORS header), AND no handler ships a same-origin redundant OPTIONS preflight handler. Browser-issued cross-origin POSTs to the auth surface return a CORS error instead of succeeding. `npm run test:smoke` continues to pass (the smoke spec is same-origin via the Vercel preview URL, so it is unaffected). skip: - role-design-system-auditor - role-a11y-auditor - role-ux-reviewer - role-ia-architect status: in-progress created: 2026-05-24 parent: ship-readiness addresses: P0 #5 (PARTIAL → RESOLVED) depends_on: - fix-auth-bypass (Brief 4, shipped — login + register CORS removal is the precedent) --- # Convoy: cors-tighten Drop wildcard `Access-Control-Allow-Origin: *` from the remaining `pages/api/**` handlers. The `fix-auth-bypass` Brief 4 already cleaned login + register; the documented follow-up was just `pages/api/auth/verify.js`, but a fresh audit (parent grep at convoy creation, 2026-05-24) found **24 files** repo-wide carrying the identical scaffolded pattern: ```js // Set CORS headers res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', '...'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); // Handle preflight requests if (req.method === 'OPTIONS') { res.status(200).end(); return; } ``` The 24 files (spot-checked against `pages/api/cards/search.js`, `pages/api/collections.js`, `pages/api/user/avatar.js` — all identical except for the `Allow-Methods` verb list): ``` pages/api/admin/index.js pages/api/auth/verify.js pages/api/cards/[id]/ownership.js pages/api/cards/owned.js pages/api/cards/search.js pages/api/collections.js pages/api/collections/[identifier].js pages/api/collections/[identifier]/activity.js pages/api/collections/[identifier]/cards.js pages/api/collections/[identifier]/permissions.js pages/api/collections/[identifier]/thumbnails.js pages/api/community/collections.js pages/api/favorites.js pages/api/invite/accept.js pages/api/invite/decline.js pages/api/public/collections.js pages/api/user/avatar.js pages/api/user/avatar/generate.js pages/api/user/delete.js pages/api/user/password.js pages/api/user/profile.js pages/api/user/settings.js pages/api/user/stats.js pages/api/users/search.js ``` ## Why now P0 #5 in `.convoys/ship-readiness.md` was marked PARTIAL on 2026-05-23 because Brief 4 only fixed the auth surface (login, register) under that convoy's narrow auth-bypass mandate. The "Queued convoys" entry assumed `cors-tighten` would be a one-file follow-up on `verify.js`. The 23-file gap is a fresh discovery. The wildcard `Access-Control-Allow-Origin: *` allows any origin to read API responses from authenticated browser sessions. Combined with the JWT-in-`Authorization`-header pattern this is less exploitable than cookie-based sessions would be (browsers won't attach the token automatically across origins), but the wildcard still: 1. **Enables credential stuffing from third-party origins** — attacker can serve a page that POSTs to `/api/auth/login` with guessed credentials and read the response (success/failure + token). The 5-attempt/15-min rate limit from `lib/rate-limit.js` mitigates volume but not the attack-class. 2. **Enables arbitrary cross-origin reads of any authenticated GET response** if a victim manually attaches a Bearer token in the wrong browser context (or if a downstream consumer ever pivots to cookies, which `single-auth-provider` may eventually do). 3. **Defeats Vercel's same-origin-by-default deployment shape** — `tcg-vault.com` (or whatever it ends up being post-`pick-a-name`) and the API are served from the same Vercel project. There is no legitimate cross-origin caller. The header is purely scaffolding cruft from whatever generator created the original route templates. This convoy is launch sequence step 4 in `.convoys/ship-readiness.md`'s "Proposed launch sequence" (originally `add-rate-limiting`'s slot, but `cors-tighten` was queued separately and is logically prior — fixing CORS first means rate-limiting's protection isn't side-stepped by a cross-origin caller). ## Scope — TWO OPTIONS, architect picks at gate 1 ### Option A — Narrow (matches the documented queued convoy entry) - Drop wildcard CORS + OPTIONS handler from `pages/api/auth/verify.js` ONLY. ~10 LOC deletion. Closes P0 #5 from PARTIAL → RESOLVED-on-auth-surface. - Queue a separate `cors-sweep-all-routes` convoy for the remaining 23 files. Adds friction (two PRs, two doc-writer cleanups), but matches the convoy's original documented scope. ### Option B — Expanded (recommended by parent, pending architect ratification) - Drop wildcard CORS + OPTIONS handler from **all 24 files** in one PR. ~240 LOC deletion across 24 files, mechanically identical to what Brief 4 did to login + register. Closes P0 #5 fully — PARTIAL → RESOLVED. - Single mechanical sweep; no per-file design decisions; smoke + vitest defend against regression. - Same precedent shape applies (Brief 4's commit `297afca` is the reference diff). **Architect's responsibility at gate 1:** confirm Option B is mechanically safe (no file in the 24 has unique pre-OPTIONS body logic that depends on the wildcard, no file is doing a *narrow* CORS hint that should be preserved-but-tightened rather than deleted), or recommend Option A with explicit reasoning. Default recommendation is B — same-origin Vercel deployment means CORS headers serve no legitimate purpose anywhere on this API surface. ## Operator action required **None.** No env vars, no secrets, no infra changes. ## Decisions to ratify with operator 1. **Option A (narrow) vs Option B (expanded).** See Scope § above. Parent recommends Option B; architect investigates and ratifies. 2. **Should the OPTIONS preflight handler be replaced with a `405 Method Not Allowed`?** Once the wildcard CORS is gone, browsers will not send preflights to this API (same-origin doesn't preflight). Three sub-options: - **(a)** Delete the OPTIONS handler entirely. Method-check at top of handler (`if (req.method !== 'POST')`) returns 405. Same end-state as Brief 4 did to login + register. - **(b)** Keep the OPTIONS handler, return 405. Slightly friendlier to any future direct-CLI callers, but contradicts the "no special-case OPTIONS" simplification. - **(c)** Keep the OPTIONS handler, return 204. Strictly correct per RFC 7231 for an empty success response. Recommend (a). Matches Brief 4 precedent. Trivial to revisit if a real cross-origin caller ever lands. 3. **`pages/api/auth/verify.js` is a GET endpoint** — its `Allow-Methods` line is `'GET, POST, PUT, DELETE, OPTIONS'` (over-permissive). Should the method gate be tightened to GET-only at the same time? Recommend YES — the handler already has `if (req.method !== 'GET') return 405` at line 17, so tightening the `Allow-Methods` line is moot once it's deleted. No-op. 4. **Any tests need updating?** Vitest suite covers `pages/api/auth-utils.js` and `lib/permission-middleware.js` but NOT the per-route handlers directly. Smoke suite covers `home`, `sign-in`, `/api/health` — none of the 24 routes in scope are smoke-covered today, and smoke uses same-origin so CORS removal won't affect it. Recommend NO new tests in this convoy (deferred to a per-route handler test convoy that doesn't exist yet). ## Known constraints - **All 24 files use the IDENTICAL scaffolded pattern** (parent spot-checked 3 of 24; architect should spot-check 5+ more to rule out drift). Same 3-line CORS block, same OPTIONS-guard block. Differs only in the `Allow-Methods` verb list. - **Brief 4 of `fix-auth-bypass` (commit `297afca`)** is the exact precedent. The shape there was: delete the comment, delete the 3 setHeader calls, delete the if-OPTIONS block. That is the edit to apply 24 times. - **No lib-level change.** No need to add a "CORS helper module" or anything else — the right answer is "no CORS at all", same as Brief 4 settled on. - **CI workflow `forbidden-endpoints` job** does NOT currently forbid CORS headers. If we want a regression lock, the architect could optionally add a new CI grep-gate. Recommend YES if Option B is chosen (locks in the cleanup so it can't accumulate again). - **Same-origin assumption holds** — the API and frontend live on the same Vercel project (same domain). If that ever changes (separate API subdomain, mobile app calling the API directly), a real CORS layer needs to be designed at that point. This convoy explicitly does NOT design for that future — YAGNI. ## Acceptance criteria The convoy is shippable when ALL of the following hold: 1. The targeted files (`verify.js` for Option A; all 24 for Option B) have zero `Access-Control-Allow-Origin` references. 2. The targeted files have zero `if (req.method === 'OPTIONS')` blocks. 3. `npm run lint` exit code matches baseline (still 128 problems per `fix-lint-baseline`; do NOT regress). 4. `npm run test:run` (vitest) still passes 21/21 (no regression). 5. `npm run test:smoke` (via CI on the PR) still passes 3/3 against the Vercel preview — proves login + verify + /api/health flow end-to-end after the CORS removal. 6. `git grep -nE "Access-Control-Allow-Origin" -- 'pages/api/**'` returns zero matches (Option B) OR exactly N-1 matches where N=23 (Option A). 7. If Option B AND the architect picks "add CI regression lock": `.github/workflows/ci.yml`'s `forbidden-endpoints` job (or a new `forbidden-cors-headers` job) fails when any new `Access-Control-Allow-Origin` is reintroduced. ## Anything flagged but not acted on (in advance) - **A real CORS layer for a future mobile / 3rd-party API consumer.** Out of scope. If/when needed, design from scratch (probably as middleware) rather than re-scaffolding wildcards. - **`cors-sweep-all-routes` follow-up** — only relevant if Option A is chosen. Pre-queue the entry in ship-readiness's Queued convoys section if Option A wins. - **API-route handler unit tests** — none of the 24 files have vitest coverage today. Adding handler-level tests for each is a separate convoy (probably `fill-vitest-handler-coverage`). - **OPTIONS / CORS via Next.js middleware** — `middleware.js` doesn't exist. Adding a middleware layer to enforce same-origin is an over-engineered fix for "remove unnecessary headers"; YAGNI. Note for posterity in case a future agent considers it. ## Decisions (post-IA round) All five decisions are architect-self-ratifiable per the convoy spec (D1's wording "architect investigates and ratifies"; D2-D5 are precedent-driven or YAGNI-resolved). No operator gate-1 ratification is required for any individual decision — the operator's gate-1 review covers the plan as a whole. ### D1. Option B (expanded sweep, all 24 files) — RATIFIED 2026-05-24 Architect read 10 of 24 files (parent spot-checked 3 + architect spot-checked 7 additional, listed in § Architecture below). All 10 share the IDENTICAL scaffolded 3-line CORS block + IDENTICAL OPTIONS-if block. Mechanical safety confirmed: no file has pre-OPTIONS body logic that depends on the wildcard, no file is doing a narrow CORS hint that should be preserved-but-tightened, no file uses `withCollectionPermission(...)` (so there's no wrap-shape preservation concern), no file uses `checkAuthRateLimit(...)` (so there's no gate-ordering concern). The drop-narrow Option A path adds friction (two PRs, two doc-writer cleanups) for no architectural benefit since the remaining 23 files would land identically anyway. Option B closes P0 #5 from PARTIAL → RESOLVED in one PR. ### D2. Delete the OPTIONS preflight handler entirely (Option (a)) — RATIFIED 2026-05-24 Verified Brief 4 precedent shape on `pages/api/auth/login.js` and `pages/api/auth/register.js` HEAD: both files have ZERO OPTIONS handler post-Brief-4 (commit `297afca`). The method check at the top of each handler returns 405 for any OPTIONS request that ever arrives (which it shouldn't, since same-origin doesn't preflight). Trivial to revisit if a real cross-origin caller ever lands. Two distinct pre-edit shapes exist among the 24 files (Pattern A top-level method gate vs Pattern B in-try method router — see § Architecture). Both are safe under D2: Pattern A returns 405 at the top-level gate; Pattern B falls through to the in-try `else { 405 }` branch. ### D3. `verify.js` `Allow-Methods` tightening — MOOT (subsumed by D2) Pre-sweep `verify.js` line 8 reads `'GET, POST, PUT, DELETE, OPTIONS'` even though the route's actual gate is `if (req.method !== 'GET')` at line 17. Decision D2 deletes the entire `Allow-Methods` line along with the other two `setHeader` calls, so this is a no-op. Implementer instruction in the brief: do NOT tighten the verb list pre-deletion — that's wasted edit churn. ### D4. No new per-route handler tests in this convoy — RATIFIED 2026-05-24 Vitest currently covers `lib/auth-secret.js`, `lib/permission-middleware.js`, `pages/api/auth-utils.js`, and `components/Layout.js` (21 tests total) — none of the 24 swept files. Smoke covers `/`, `/login`, `/api/health` — also none of the 24. Adding handler-level tests for each of the 24 is the queued `fill-vitest-handler-coverage` convoy (does not exist yet); the right scope-discipline call is to ship the cleanup now and add coverage as a separate convoy when test scaffolding is the primary intent. ### D5. Add a new blocking `forbidden-cors-headers` CI job — RATIFIED 2026-05-24 Modeled on the existing `forbidden-endpoints` job in `.github/workflows/ci.yml` (added by `fix-auth-bypass` Brief 3). Bash grep across `pages/api/` for any of `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers`. Hits emit `::error file= line=::` annotations and exit 1. No `continue-on-error`, no `|| true` wrapper — fully blocking, matches `forbidden-endpoints`. Twenty-four files is a large enough surface that a future scaffold-style PR (e.g. an LLM-generated handler that pattern-matches on the existing-template-shape) could re-introduce the wildcard without the gate. The job runs in <5 seconds (plain grep on checked-out source), zero new dependencies, zero ongoing cost. ## Architecture ### File plan | File | Action | Purpose | | --- | --- | --- | | `pages/api/admin/index.js` | modified | Delete CORS block + OPTIONS if | | `pages/api/auth/verify.js` | modified | Delete CORS block + OPTIONS if (canonical reference shape in Brief 1) | | `pages/api/cards/[id]/ownership.js` | modified | Delete CORS block + OPTIONS if | | `pages/api/cards/owned.js` | modified | Delete CORS block + OPTIONS if | | `pages/api/cards/search.js` | modified | Delete CORS block + OPTIONS if | | `pages/api/collections.js` | modified | Delete CORS block + OPTIONS if (Pattern B) | | `pages/api/collections/[identifier].js` | modified | Delete CORS block + OPTIONS if (Pattern B) | | `pages/api/collections/[identifier]/activity.js` | modified | Delete CORS block + OPTIONS if | | `pages/api/collections/[identifier]/cards.js` | modified | Delete CORS block + OPTIONS if | | `pages/api/collections/[identifier]/permissions.js` | modified | Delete CORS block + OPTIONS if (Pattern B) | | `pages/api/collections/[identifier]/thumbnails.js` | modified | Delete CORS block + OPTIONS if | | `pages/api/community/collections.js` | modified | Delete CORS block + OPTIONS if | | `pages/api/favorites.js` | modified | Delete CORS block + OPTIONS if | | `pages/api/invite/accept.js` | modified | Delete CORS block + OPTIONS if | | `pages/api/invite/decline.js` | modified | Delete CORS block + OPTIONS if | | `pages/api/public/collections.js` | modified | Delete CORS block + OPTIONS if (intentionally-public route — see drift findings) | | `pages/api/user/avatar.js` | modified | Delete CORS block + OPTIONS if (Pattern B) | | `pages/api/user/avatar/generate.js` | modified | Delete CORS block + OPTIONS if | | `pages/api/user/delete.js` | modified | Delete CORS block + OPTIONS if | | `pages/api/user/password.js` | modified | Delete CORS block + OPTIONS if | | `pages/api/user/profile.js` | modified | Delete CORS block + OPTIONS if | | `pages/api/user/settings.js` | modified | Delete CORS block + OPTIONS if | | `pages/api/user/stats.js` | modified | Delete CORS block + OPTIONS if | | `pages/api/users/search.js` | modified | Delete CORS block + OPTIONS if | | `.github/workflows/ci.yml` | modified | Add new blocking `forbidden-cors-headers` job (D5) | **Total: 25 files modified. No new files. No deletions. No schema changes. No new dependencies.** ### API surface No API surface changes (same routes, same methods, same auth requirements, same response shapes, same rate-limit considerations as today). The only externally-observable behavior change is: - Cross-origin browser requests no longer succeed (browser blocks them at the CORS layer post-sweep — the desired success-metric end state). - Same-origin requests (the existing frontend) continue to work unchanged. - A direct `OPTIONS` request that bypasses the same-origin policy (e.g. `curl -X OPTIONS`) returns 405 instead of 200. Strictly safer. ### Schema diff None. No DDL, no migration, no `docs/SCHEMA_MAP.md` update needed. ### Pattern-drift audit results Architect spot-checked 7 files (parent spot-checked 3 additional, listed in the convoy's "Known constraints" section). All 10 confirmed identical scaffolded pattern with two structural sub-shapes: | File | Sub-shape | Drift? | | --- | --- | --- | | `pages/api/cards/search.js` (parent) | Pattern A | None — identical | | `pages/api/collections.js` (parent) | Pattern B | None — identical CORS block; method routes inside `try`, post-OPTIONS removal falls through to `else { 405 }` | | `pages/api/user/avatar.js` (parent) | Pattern B | None — identical CORS block | | `pages/api/auth/verify.js` (architect) | Pattern A | None — identical; canonical reference shape locked in Brief 1 | | `pages/api/admin/index.js` (architect) | Pattern A | None — identical CORS block at top of handler (helper functions are above the handler) | | `pages/api/user/avatar/generate.js` (architect) | Pattern A | None — identical | | `pages/api/cards/[id]/ownership.js` (architect) | Pattern A | None — identical; the `req.query.id` parse happens INSIDE the post-OPTIONS-removal `try` block, no pre-OPTIONS dependency on `[id]` | | `pages/api/collections/[identifier]/permissions.js` (architect) | Pattern B | None — identical CORS block; method routes inside `try`, post-OPTIONS removal safe (auth → identifier parse → method branch → `else { 405 }` for OPTIONS) | | `pages/api/invite/accept.js` (architect) | Pattern A | None — identical | | `pages/api/public/collections.js` (architect) | Pattern A | None — identical; the only "intentionally public" route, but no documented cross-origin consumer (see § Public-routes finding below) | | `pages/api/cards/owned.js` (architect bonus) | Pattern A | None — identical | | `pages/api/collections/[identifier].js` (architect bonus) | Pattern B | None — identical CORS block; method-branched inside try | | `pages/api/community/collections.js` (architect bonus) | Pattern A | None — identical | | `pages/api/invite/decline.js` (architect bonus) | Pattern A | None — identical | **Conclusion: zero drift across the 10-file audit. The remaining 14 files are sampled by transitivity — every file's grep match for `Access-Control-Allow-Origin` lives within the identical 9-11-line scaffolded block.** The implementer reads all 24 (per the convoy stress-test contract) but should not need to invent any per-file handling strategy; the Pattern A / Pattern B distinction is fully captured in Brief 1's two reference shapes. ### Public-routes finding `pages/api/public/collections.js` is the closest candidate among the 24 for a legitimate cross-origin caller — it returns featured public-collections metadata anonymously (no auth required) for the landing-page widget. The architect's conservative call (D1 + D2): still sweep. Reasoning: 1. **Same-origin frontend.** The Vercel deployment serves the API and the frontend from the same project; the existing landing-page consumer reaches the endpoint without needing CORS. 2. **No documented external consumer.** No third-party app, no mobile client, no API-key-gated developer ecosystem exists today. YAGNI. 3. **Sweep-and-revisit is cheap.** If a third-party consumer ever lands, a proper CORS layer (Next.js middleware OR explicit `Access-Control-*` headers gated on `process.env.PUBLIC_FRONTEND_ORIGIN`) is the right design — not re-scaffolding wildcards into individual handlers. Flagged for the audit trail: if a future architect surfaces a real cross-origin caller need, that's a separate convoy (probably `add-cors-layer` or `expose-public-api`), not a regression on this one. ### Risk list - **R1 — Method-check ordering on Pattern B files.** Two of 24 (`collections.js`, `collections/[identifier].js`, `collections/[identifier]/permissions.js`, `user/avatar.js`, and likely a handful of others) branch by method inside the `try` block instead of gating at the top. Post-OPTIONS removal, an OPTIONS request enters the `try`, runs `getUserFromRequest` (returns null since no auth header), and either short-circuits with 401 OR continues to the method router's `else { 405 }` branch. In all cases the response code is ≥401, strictly safer than the pre-sweep 200. **Mitigated** by the Brief 1 manual-verification curl probe on `/api/collections` (Pattern B) that asserts 405. - **R2 — Smoke spec regression.** Smoke hits `/`, `/login`, `/api/health` — none in scope. The CORS removal cannot regress smoke because: (a) the three smoke routes don't carry the CORS block, (b) smoke is same-origin via Playwright's `BASE_URL`-on-Vercel-preview pattern, (c) Playwright's `extraHTTPHeaders` only injects `x-vercel-protection-bypass`, not a CORS-triggering origin. **Mitigated** by the smoke spec's existing CI run on the PR. - **R3 — Lint baseline regression.** The current baseline is 128 problems (per `bump-next-js` Decision D). The sweep is pure deletion; it cannot introduce new findings. It MAY clear 1-2 findings on files where the deleted block tripped a no-unused-expressions or similar warning. **Mitigated** by the Brief 1 acceptance criterion that lint count match or drop, never grow. - **R4 — Mid-edit syntax errors.** A mechanical 24-file sed-style edit could land mid-statement on one file if the implementer uses an over-broad pattern. **Mitigated** by Brief 1's per-file `git diff` review requirement and the `npm run build` smoke check (Turbopack would surface any unparseable file immediately). - **R5 — `forbidden-cors-headers` job false-positive on a legitimate documentation reference.** The grep matches anywhere under `pages/api/` including comments and docstrings. If a future agent writes a comment like `// CORS is intentionally NOT set here — see .convoys/cors-tighten.md`, the grep would catch it. **Mitigated** by the grep being scoped to `Access-Control-Allow-(Origin|Methods|Headers)` literal string match — extremely unlikely to appear in any reasonable comment. If it ever does, the comment can use different wording (e.g. "wildcard origin"). - **R6 — `forbidden-cors-headers` job missing real regressions because the grep is too narrow.** If a future agent reintroduces CORS via `res.setHeader('access-control-allow-origin', '*')` (lowercase) or via `res.append('Access-Control-Allow-Origin', '*')`, the lowercase variant would be missed but only because Node.js HTTP headers are case-insensitive on read, not on write — the grep matches the literal source string the developer wrote. The conventional capitalization used by every existing site (and the original scaffolded template) is `Access-Control-Allow-Origin`. **Mitigated** by the grep's case-sensitive default; if false-negatives become a real risk in the future, `grep -iE` is a one-character change. - **R7 — Implementer sweeps `login.js` / `register.js` by accident.** Brief 1 explicitly lists them as out-of-scope. The pre-sweep grep baseline (24 files) and the post-sweep grep baseline (0 files) make a sweep of these two visible — the `git diff` would show them as changed, but the diff would be no-op (they have nothing to delete). **Mitigated** by the Brief's explicit out-of-scope list and the diff-hygiene acceptance criterion (deletion count per file ≈ 9-11 lines; a no-op file would show 0). ### Test plan No new tests this convoy (Decision D4). Existing coverage continues to defend: - **Vitest (21/21):** unchanged. Verifies on push via the `test` job in `.github/workflows/ci.yml` (blocking). - **Playwright smoke (3/3):** unchanged. Verifies on push via `.github/workflows/preview-smoke.yml`. - **Visual diff:** unchanged behavior (still fails on missing baseline until `seed-visual-baselines-on-linux` lands; that's the documented Decision-4 end state of `adopt-playwright-smoke`). - **Lint (`|| true` wrapped):** baseline must match (128 problems) or drop, never grow. - **New `forbidden-cors-headers` CI job:** locks in the sweep against future regressions. Blocking on the PR. If `fill-vitest-handler-coverage` ever lands, the per-route handler tests should explicitly assert (a) response headers do NOT include any `Access-Control-Allow-*` and (b) `OPTIONS` returns 405. That's a separate convoy's scope. ## Decomposition | Brief # | Title | Files | Depends on | Estimated PR size | | --- | --- | --- | --- | --- | | 1 | Sweep wildcard `Access-Control-Allow-Origin` from all 24 remaining API handlers + add CI regression-lock | 24 source files + `.github/workflows/ci.yml` | none | ~260 LOC (240 deletions across 24 files + ~20-25 lines added to ci.yml) | Single brief is the right decomposition because: 1. **Mechanical sweep, no per-file decisions.** Every file's diff is structurally identical (Pattern A or Pattern B, both documented verbatim in Brief 1). Splitting into N briefs would multiply doc-writer overhead with zero architectural benefit. 2. **CI regression-lock belongs in the same PR.** Landing the grep gate in a separate brief creates a window where a re-scaffolded handler could slip in undetected (and forces the regression-lock to grep-check against an empty cleanup, which would be a no-op). 3. **Under-400-LOC threshold honored.** ~260 LOC of diff fits comfortably under the architect-contract's brief-size budget. 4. **Reviewable as a single diff.** Reviewers can grep-spot-check the 24 files in seconds (every diff should be a pure deletion of the same 9-11 lines); the new CI job is a single self-contained block. ### Slice dependencies (multitask-ready) ```yaml slice_dependencies: - brief: 1 depends_on: [] files: - pages/api/admin/index.js - pages/api/auth/verify.js - pages/api/cards/[id]/ownership.js - pages/api/cards/owned.js - pages/api/cards/search.js - pages/api/collections.js - pages/api/collections/[identifier].js - pages/api/collections/[identifier]/activity.js - pages/api/collections/[identifier]/cards.js - pages/api/collections/[identifier]/permissions.js - pages/api/collections/[identifier]/thumbnails.js - pages/api/community/collections.js - pages/api/favorites.js - pages/api/invite/accept.js - pages/api/invite/decline.js - pages/api/public/collections.js - pages/api/user/avatar.js - pages/api/user/avatar/generate.js - pages/api/user/delete.js - pages/api/user/password.js - pages/api/user/profile.js - pages/api/user/settings.js - pages/api/user/stats.js - pages/api/users/search.js - .github/workflows/ci.yml ``` Single-brief slice; conductor dispatches one implementer (no `/multitask` fan-out applicable). Architect complete. 1 brief created. Estimated PRs: 1. Awaiting human gate 1 (plan approval) before implementer runs.