Skip to content

OPS-T1 corpus rollout — ASSESS (Task ID-50.1)

OPS-T1 corpus rollout — ASSESS (Task ID-50.1)

Section titled “OPS-T1 corpus rollout — ASSESS (Task ID-50.1)”

Status: ASSESS-S267. Decision-informing for the {50.2} PLAN. No production code changed this session (read-only against the INSTALLED tree + a temp-dir codemod dry-run). Scope discipline: this is the step-back assessment the S262 saga mandates — every finding is grounded against installed code (lib/api/define-route.ts, scripts/codemods/wrap-define-route.ts, lib/logger/request-context.ts, the live app/api/** corpus), not against the Task record’s prose. Inputs: the proven codemod + pass-through wrapper (ID-32, DONE), ID-32.27 gate journal (118 TS2345 + 24 RSVE, proven on the temp-copy gate), ID-32.28 369-error analysis, 07-collapse-list.md §6/§8/§12, the s37 test-audit (consolidated-findings.md + remediation-plan.md), research §7, OPS-T1 PLAN §0/§4a.


0. What ID-50 inherits (the premise, restated against installed code)

Section titled “0. What ID-50 inherits (the premise, restated against installed code)”

ID-32 shipped the codemod proven on a temp copy only — the working tree is GREEN with zero routes wrapped (tsc 0 / next build 0 / knip OK per the ID-32.27/.28 journals; re-confirmed: enumerateRouteFiles finds 195 routes, the dry-run reports SKIPPED = 0, i.e. nothing is already wrapped). ID-50 is the mandatory corpus rollout — Option-4 steps 3 + 4 of research §7’s incremental adoption plan: ship the runtime wrapper for the routes where it is mechanical and the schema is strict, then drive type-drift-detect --ci baseline to zero.

Applying the codemod --apply to the working tree was proven by the gate to carry a ~369-error tsc blast radius, which decomposes into three distinct, independently-addressable classes:

ClassCount (gate-proven)KindFix owner
Ctx-contravariance118 TS2345compilegeneric defineRoute ctx type — §1
Test-call-site signature mismatchthe bulk (~remaining tsc)compiletest-call-site migration — §2
Strictness-drift24 RSVE in 7 filesruntimeschema/handler reconciliation — §2.3

The 118 + 24 are the gate’s exact figures; the test-call-site total is the remainder of the 369 (lower-bounded empirically at 104 zero-arg handler calls in __tests__/api/, see §2.1). All three are mechanical or near-mechanical; none is a B3-style no-undef defect (the gate confirmed every emitted defineRoute(...) resolves its z + schema imports).


1. (a) Generic defineRoute ctx type — resolving the 118 TS2345

Section titled “1. (a) Generic defineRoute ctx type — resolving the 118 TS2345”

1.1 The contravariance, located in installed code

Section titled “1.1 The contravariance, located in installed code”

The shipped wrapper (lib/api/define-route.ts:208-214) types the handler param as:

export function defineRoute<S extends z.ZodTypeAny>(
schema: S,
handler: (request: NextRequest, ctx?: RouteHandlerContext) => Promise<Response | z.infer<S>>,
): WrappedRoute // = (request: NextRequest, ctx?: RouteHandlerContext) => Promise<Response>

with RouteHandlerContext = { params?: Promise<unknown> } (:77-79).

Dynamic routes annotate ctx narrowly and non-optionally. Verified:

  • app/api/layers/[id]/route.ts:20-22PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> })
  • app/api/items/[id]/route.ts:41 + :953withRequestContext(patchHandler) where patchHandler(request, { params }: { params: Promise<{ id: string }> })

{ params: Promise<{ id: string }> } is not a supertype of { params?: Promise<unknown> } (required vs optional; {id} vs unknown), so by function-parameter contravariance the route’s handler is not assignable to the wrapper’s handler param → TS2345. This fires on every parameterised route whose handler annotates its params (the PARAM 26 + PARAM_BODY 39 + MULTI_PARAM 4 + MULTI_PARAM_BODY 18 families, plus the +WRC compositions) — consistent with the gate’s 118.

The +WRC path compounds it. withRequestContext (lib/logger/request-context.ts) is overloaded; overload 3 (:181-189) expects a handler (request, ctx: { params: Promise<TParams> }) => Response and returns the same shape. After the codemod produces withRequestContext(defineRoute(Schema, handler)), the inner defineRoute(...) result is a WrappedRoute whose ctx is { params?: Promise<unknown> } — too wide for overload 3’s { params: Promise<TParams> } → TS2345 at the composition.

This is NOT a +WRC-only problem. The Task record frames it as “+WRC contravariance”, but the grounded mechanism is every parameterised handler that annotates params. The fix is therefore a precondition for wrapping any dynamic route, not a +WRC mop-up.

1.2 Two viable designs — recommendation + the empirical tiebreak

Section titled “1.2 Two viable designs — recommendation + the empirical tiebreak”

Design A — overload pair mirroring withRequestContext (conservative).

// collection / naked (zero- or one-param handlers)
export function defineRoute<S extends z.ZodTypeAny>(
schema: S, handler: (request: NextRequest) => Promise<Response | z.infer<S>>,
): (request: NextRequest) => Promise<Response>;
// dynamic — TParams flows through, matching withRequestContext overload 3 exactly
export function defineRoute<S extends z.ZodTypeAny, TParams>(
schema: S, handler: (request: NextRequest, ctx: { params: Promise<TParams> }) => Promise<Response | z.infer<S>>,
): (request: NextRequest, ctx: { params: Promise<TParams> }) => Promise<Response>;

The dynamic overload’s return type is byte-identical to withRequestContext overload 3’s expected handler shape → the +WRC composition type-checks. Proven analog: withRequestContext already wraps these exact routes today.

Design B — single variadic-tuple generic (faithful, smaller blast radius).

export function defineRoute<S extends z.ZodTypeAny, Args extends unknown[]>(
schema: S, handler: (...args: Args) => Promise<Response | z.infer<S>>,
): (...args: Args) => Promise<Response>;

Args captures the handler’s exact parameter tuple — [] for the zero-param collection handlers (app/api/intelligence/workspaces/route.ts:15 is export async function GET()), [NextRequest, { params: Promise<{id}> }] for dynamic routes. The wrapper’s signature is then identical to the original handler’s.

Why B is likely the smaller migration. Under A, a wrapped collection export is typed (request: NextRequest) => Promise<Response>request becomes required, so the ~104 zero-arg test calls (listGET(), §2.1) all break with TS2554. Under B, a zero-param handler keeps a () => Promise<Response> wrapper, so listGET() still compiles and those test edits disappear. B trades a slightly looser export type (a GET typed () => …) for a materially smaller §2 migration.

Recommendation: implement Design B, validate on the temp-copy probe, fall back to Design A if B leaves residual TS2345 or trips the Next.js route-export type-check. The {50.3} impl Subtask decides empirically — apply each candidate to a git archive temp copy (the {32.25} harness) and measure residual tsc errors over migrated routes. The choice is an in-OPS-T1 implementation detail (no cross-Task scope), so no parent OQ — but it directly sizes the §2 migration, hence is the first slice.

File + line: lib/api/define-route.ts — the defineRoute signature :208-214, the WrappedRoute type :86-89, RouteHandlerContext :77-79, and the body’s handler(request, ctx) call :219 (Design B refactors the body to read args[0] as the request for routeIdFor). The runtime behaviour (INV-PT / INV-FP) is unchanged — this is a typing-surface change only.


2.1 The dominant error class — arity (TS2554), located + sized

Section titled “2.1 The dominant error class — arity (TS2554), located + sized”

Route tests import handlers aliased and invoke them directly. Verified in __tests__/api/intelligence/workspaces.test.ts:40-47 + :165:

import { GET as listGET, POST as listPOST } from '@/app/api/intelligence/workspaces/route';
...
const response = await listGET(); // zero args — collection GET() takes none today
const response = await listPOST(request); // one arg

The collection handler is export async function GET() (zero params), so listGET() compiles today. After wrapping (under Design A) the export becomes (request: NextRequest) => Promise<Response>listGET() is TS2554 “Expected 1 arguments, but got 0”. 107 test files import handlers from @/app/api/.../route; 104 zero-arg await <alias>(GET|POST|…)() invocations exist across __tests__/api/ (heuristic lower bound). This is the bulk of the non-118 tsc errors.

A secondary class: the wrapped export’s return type widens from Promise<NextResponse<X>> to Promise<Response>. response.json() still returns a thenable, so most await response.json() assertions survive; only sites that read a typed field off the response object (not off the parsed body) need a cast.

2.2 The replacement pattern (mechanical) + recommendation

Section titled “2.2 The replacement pattern (mechanical) + recommendation”

A canonical request factory already exists — createTestRequest(path, options) and createTestParams<T>(params) in __tests__/helpers/mock-next.ts:47 / :77. The migration is therefore:

const response = await listGET();
// →
const response = await listGET(createTestRequest('/api/intelligence/workspaces'));

The injected request is runtime-harmless for handlers that ignore it (the zero-param case) and the path is derivable from the import source (@/app/api/X/route/api/X). This is mechanisable.

Recommendation: codemod-assisted, NOT a from-scratch hand-migration. Author a small ts-morph transform (sibling to wrap-define-route.ts) that, for each route- test file, finds calls to imported route-handler symbols that are now under-arity and injects createTestRequest('<derived-path>') (adding the helper import if absent). Hand-review the handful that read query params (already pass a request). This mirrors the codemod discipline ID-32 proved. Crucially, it runs LOCKSTEP with wrapping — you cannot migrate a call-site before its route is wrapped (calling a zero-param unwrapped GET() with an arg is itself a TS2554 on the unwrapped side). So the migration is distributed across the per-group waves (§3), not a standalone pre-pass. If Design B (§1.2) is chosen, the zero-arg subset evaporates and the migration shrinks to genuine signature changes only.

2.3 The 24 RSVE strictness-drift — runtime, 7 named files

Section titled “2.3 The 24 RSVE strictness-drift — runtime, 7 named files”

Distinct from the compile errors: when the wrapped route suite runs under the strict R-WP17 schemas (ID-32.26), 24 responses fail safeParse LOUD (ResponseSchemaValidationError) across 7 files, all confirmed present:

__tests__/api/intelligence/{workspaces,profiles,sources,sources-test-poll-web}.test.ts, procurement-responses-crud.test.ts, review.test.ts, review/assignments.test.ts.

These are real shape drift the strict schema now rejects (the S262 lesson working: a tightened schema catches what .loose() masked). Reconcile by correcting the route’s response shape or the bound schema to match reality — never by .loose() masking (the explicit ID-32.26 / DEFECT-B5 discipline). These routes are wrapped in the intelligence/procurement/review groups (§3 waves B/C), so the reconciliation is a dedicated slice gated on those waves.


3. (c) Incremental-green vs big-bang — recommend INCREMENTAL

Section titled “3. (c) Incremental-green vs big-bang — recommend INCREMENTAL”

--apply with no --scope wraps all 177 routes at once → 369 simultaneous red errors, un-bisectable. The codemod already supports --scope <path-fragment> (wrap-define-route.ts:250-273), so per-route-group waves are first-class.

Recommended ordering (each wave ends tsc + next build green):

  1. Generic ctx type ({50.3}) — precondition; no routes wrapped; backward- compatible signature widening (existing call sites still compile).
  2. Migration tooling ({50.4}) — author + temp-copy-validate the call-site transform (§2.2); no working-tree wrapping.
  3. Per-group wrap+migrate waves ({50.5}–{50.9}) — each: --scope app/api/<seg> wrap + apply the {50.4} transform to that group’s tests + green gate. Grouped by the live segment distribution (below).
  4. Strictness-drift reconciliation ({50.10}) — the 24 RSVE / 7 files.
  5. Close ({50.11}) — all-wrapped verify + AC-10 type-drift-detect baseline to zero + document the upload carve-out.

Big-bang is rejected: the gate already proved the all-at-once state is 369-red, and the S262 mandate is “green per slice, continuously” (PLAN §0). Incremental also lets the continuous real-corpus probe run per wave as designed.


4. (d)+(e) Scope — the MECHANISABLE set after the retirement pre-filter

Section titled “4. (d)+(e) Scope — the MECHANISABLE set after the retirement pre-filter”

Regenerated (the stale codemod-needs-manual.json only existed in a dead worktree) via CODEMOD_OUTPUT_DIR=/tmp/… bun scripts/codemods/wrap-define-route.ts against the live corpus — 195 routes:

VerdictCountDisposition
TRANSFORM (single-method MECHANISABLE)132wrapped (AUTH_PLAIN 40, BODY_VALIDATED 27, PARAM 26, PARAM_BODY 39)
NEEDS_REVIEW45wrapped + flagged (MULTI_* 39 + +WRC 6)
MANUAL18NOT wrapped — CRON 9, NAKED_NO_AUTH 7, MCP 1, UNKNOWN_WRAPPER 1
SKIPPED0(nothing already wrapped — working tree clean)

--apply rewrites TRANSFORM + NEEDS_REVIEW = 177. The 18 MANUAL are excluded by the codemod itself (MANUAL_SHAPES + NAKED_NO_AUTH/CRON/MCP/UNKNOWN_WRAPPER classification) — no action needed; they migrate by hand under a different model if ever, out of ID-50 scope.

4.1 Retirement / collapse pre-filter (cross-referenced against 07-collapse-list.md)

Section titled “4.1 Retirement / collapse pre-filter (cross-referenced against 07-collapse-list.md)”
RouteCollapse flagDispositionNote
app/api/upload/route.ts§8 + §12.2 [CONDITIONAL-RETIRE] STILL-OPENDEFER — do NOT wrapBinds at 02-data-flow.md §12.2 (source-binding-to-folder vs non-folder). Wrapping-then-deleting is wasted churn. Carved out; {50.3+} excludes it.
app/api/admin/batch-reclassify/route.ts§5.2 names the seed scripts, not the routeWRAP (default)Marginal — see OQ-pending.md OQ-50.1-A; default = wrap (route survives as wired endpoint).
app/api/change-reports/** (4 routes)§6 [RATIFIED-RENAME] digestchange-reportsWRAP — rename LANDEDVerified: no app/api/digest dir on disk; change-reports/{[id],generate,latest,list} present. Wrap the post-rename paths.
app/api/procurement/** (29 routes)§3.1 [RATIFIED-RENAME] bid_*procurement_*WRAP — rename LANDEDVerified: no app/api/bid(s) dir; procurement/** present. Wrap normally.
app/api/source-documents/[id]/diff/route.ts§12.1 RESOLVED-S243 RETAINED for v1WRAPDiff-UI repointed to markdown sidecars, kept for v1 — wrap normally (MULTI_PARAM_BODY NEEDS_REVIEW).

No [RATIFIED-RETIRE] route is in the wrap set (the §5/§7/§9/§11 retires are Python scripts, lib modules, DB objects, and deps — none are app/api/**/route.ts).

177 wrapped − 1 deferred (upload) = 176 routes. The Task title’s “~137” corresponds to the single-method TRANSFORM bucket (historically 137, now 132, minus upload = 131); the full wrap scope incl. the 45 NEEDS_REVIEW multi-method/+WRC routes is 176.

Wrapped-route distribution by top-level app/api/<segment> (drives the §3 waves):

procurement 29 | admin 20 | intelligence 20 | items 19 | tags 8 | review 7 |
entities 7 | coverage 6 | taxonomy 5 | change-reports 4 | source-documents 4 |
guides 4 | workspaces 3 | notifications 3 | layers 3 | search 3 | + ~30 long-tail
singletons (incl. the deferred `upload` — excluded)

5. (f) Audit coordination (s37 test-audit)

Section titled “5. (f) Audit coordination (s37 test-audit)”

The s37 remediation waves edit the same __tests__/api/*.test.ts files ID-50’s call-site migration touches: W-RC (9 api-test mislocation git mvs), W-RD (~23 api-test chain-method assertion rewrites). W-RE is components-tree only — no api overlap. The audit is remediation, not retirement (C1/C4 = 0 violations; its only 3 migrate-to-integration tests live in lib//hooks//fixtures/, none in __tests__/api/), so it does not shrink ID-50’s scope.

The collision is real but the ordering is forced: ID-50’s call-site migration is a compile-correctness prerequisite — a wrapped route’s test file does not type-check until its call-sites are migrated, and W-RD cannot rewrite assertions in a non-compiling file. So ID-50’s mechanical signature migration must land before the audit’s semantic assertion rewrites regardless of which “starts first”. The two edits are orthogonal (signature args vs assertion bodies), so post-ID-50 the audit applies cleanly on a green tree. {50.2} PLAN sequences ID-50-first and flags the parent (see OQ-pending.md OQ-50.2-B) to pause W-RC/W-RD on the api tree, or to hand ID-50 the post-git mv paths, if the audit is mid-flight in a parallel terminal.


6. Recommendation summary (feeds {50.2} PLAN)

Section titled “6. Recommendation summary (feeds {50.2} PLAN)”
  1. Generic ctx type first — Design B (variadic tuple) primary, Design A (overload pair) fallback, decided on the temp-copy residual-error probe. Unblocks the 118 TS2345 across all parameterised + +WRC routes. Smaller §2 migration if B.
  2. Codemod-assisted test-call-site migration, lockstep per wave, leaning on the existing createTestRequest helper.
  3. Incremental-green per-segment waves (--scope), never big-bang.
  4. Wrap 176 routes (177 − upload deferred); 18 MANUAL excluded by the codemod.
  5. Reconcile the 24 RSVE in 7 named files by fixing shapes, never .loose().
  6. Close on AC-10 type-drift-detect --ci baseline-to-zero + document the upload carve-out as ID-50-deferred (blocked on 02-data-flow.md §12.2).
  7. Sequence ID-50 call-site migration before the s37 W-RC/W-RD api waves; flag the parent on active-parallel status (OQ-50.2-B).