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 liveapp/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:
| Class | Count (gate-proven) | Kind | Fix owner |
|---|---|---|---|
| Ctx-contravariance | 118 TS2345 | compile | generic defineRoute ctx type — §1 |
| Test-call-site signature mismatch | the bulk (~remaining tsc) | compile | test-call-site migration — §2 |
| Strictness-drift | 24 RSVE in 7 files | runtime | schema/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-22—PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> })app/api/items/[id]/route.ts:41+:953—withRequestContext(patchHandler)wherepatchHandler(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 annotatesparams. The fix is therefore a precondition for wrapping any dynamic route, not a+WRCmop-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 exactlyexport 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. (b) Test-call-site migration
Section titled “2. (b) Test-call-site migration”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 todayconst response = await listPOST(request); // one argThe 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):
- Generic ctx type ({50.3}) — precondition; no routes wrapped; backward- compatible signature widening (existing call sites still compile).
- Migration tooling ({50.4}) — author + temp-copy-validate the call-site transform (§2.2); no working-tree wrapping.
- 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). - Strictness-drift reconciliation ({50.10}) — the 24 RSVE / 7 files.
- Close ({50.11}) — all-wrapped verify + AC-10
type-drift-detectbaseline to zero + document theuploadcarve-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:
| Verdict | Count | Disposition |
|---|---|---|
| TRANSFORM (single-method MECHANISABLE) | 132 | wrapped (AUTH_PLAIN 40, BODY_VALIDATED 27, PARAM 26, PARAM_BODY 39) |
| NEEDS_REVIEW | 45 | wrapped + flagged (MULTI_* 39 + +WRC 6) |
| MANUAL | 18 | NOT wrapped — CRON 9, NAKED_NO_AUTH 7, MCP 1, UNKNOWN_WRAPPER 1 |
| SKIPPED | 0 | (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)”| Route | Collapse flag | Disposition | Note |
|---|---|---|---|
app/api/upload/route.ts | §8 + §12.2 [CONDITIONAL-RETIRE] STILL-OPEN | DEFER — do NOT wrap | Binds 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 route | WRAP (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] digest→change-reports | WRAP — rename LANDED | Verified: 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 LANDED | Verified: no app/api/bid(s) dir; procurement/** present. Wrap normally. |
app/api/source-documents/[id]/diff/route.ts | §12.1 RESOLVED-S243 RETAINED for v1 | WRAP | Diff-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).
4.2 Net MECHANISABLE scope for {50.3+}
Section titled “4.2 Net MECHANISABLE scope for {50.3+}”177 wrapped − 1 deferred (
upload) = 176 routes. The Task title’s “~137” corresponds to the single-method TRANSFORM bucket (historically 137, now 132, minusupload= 131); the full wrap scope incl. the 45 NEEDS_REVIEW multi-method/+WRCroutes 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-tailsingletons (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)”- 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 +
+WRCroutes. Smaller §2 migration if B. - Codemod-assisted test-call-site migration, lockstep per wave, leaning on the
existing
createTestRequesthelper. - Incremental-green per-segment waves (
--scope), never big-bang. - Wrap 176 routes (177 −
uploaddeferred); 18 MANUAL excluded by the codemod. - Reconcile the 24 RSVE in 7 named files by fixing shapes, never
.loose(). - Close on AC-10
type-drift-detect --cibaseline-to-zero + document theuploadcarve-out as ID-50-deferred (blocked on02-data-flow.md§12.2). - 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).