OPS-T1 codemod — wrap-define-route — Product spec
OPS-T1 codemod — wrap-define-route — Product spec
Section titled “OPS-T1 codemod — wrap-define-route — Product spec”Status: AMENDED-S265 (Option-4, Liam-ratified S262). §8 reframed from compile-time codemod to runtime strict-validation. §§1–7 codemod mechanics retained. Source:
decision-OPS-T1.md(RATIFIED-S10 option (c)-only + S11 codemod brief);investigations/type-safety-strategy-research-S262.md§§2.5, 7, 8 (Option-4 fork resolution — the governing decision for §8);investigations/S10-wave-0-synthesis.md§4.1;investigations/S10-programmatic-migration-feasibility.md§2 W1;ops-t1-codemod/route-shape-inventory.md. Sibling:ops-t1-codemod/TECH.md(implementation detail — the pass-throughdefineRoutecontract + schema-strictness rules live there).
1. Why this tool exists
Section titled “1. Why this tool exists”Knowledge Hub’s 193 Next.js API route handlers return NextResponse.json(payload)
without a compile-time contract between the payload shape and the TypeScript
interface the corresponding TanStack Query fetcher expects. The
type-drift-detect query (R-WP17, shipped S9) detects this drift at CI time,
and as of S10 the R-WP17 CI gate prevents new drift from landing. However,
the existing 37 fetcher-only interfaces — declared in types/*.ts or
lib/query/fetchers.ts but unannotated in the matching route handler — remain
as documented debt.
The long-term fix (option (a) from decision-OPS-T1.md) is to adopt a typed
defineRoute(ResponseSchema, handler) wrapper that enforces structural
symmetry between the handler’s return payload and the client-side interface.
Migrating all 193 routes by hand is estimated at 16–24 hours; the
wrap-define-route codemod reduces that to approximately 8–12 hours by
automating the handler-signature rewrite for the 137 mechanisable routes (see
route-shape-inventory.md §3).
The codemod is a sibling utility — not part of ast-dataflow. It lives at
scripts/codemods/wrap-define-route.ts. ast-dataflow remains the
verifier that runs after the codemod to confirm zero new drift.
2. Who runs it and when
Section titled “2. Who runs it and when”| Actor | When | Context |
|---|---|---|
| Developer (Liam or contributor) | Once, at start of main-track Phase 1 canonical-pipeline migration | Aligns with the project_id → workspace_id and other Phase 1 workstreams |
| Developer (targeted repair) | Any time a new subsystem’s routes need wrapping | Can target a single directory with --scope |
| CI gate (post-merge verification) | After the migration PR lands | type-drift-detect --ci as the regression check (see §7) |
The codemod is not a CI step. It is a one-off migration tool run by a developer with a clean working tree, followed by manual review and a PR.
3. Modes
Section titled “3. Modes”3.1 Dry-run (default)
Section titled “3.1 Dry-run (default)”bun scripts/codemods/wrap-define-route.tsbun scripts/codemods/wrap-define-route.ts --scope app/api/intelligence/workspacesNo files are modified. The codemod analyses the route corpus and emits a diff-preview report to stdout plus two machine-readable output files:
docs/generated/codemod-dry-run.md— human-readable per-route preview showing the proposed transformation for each MECHANISABLE route.docs/generated/codemod-needs-manual.json— structured report of all routes the codemod cannot handle automatically (MANUAL and NEEDS-REVIEW shapes). This is a deliverable artefact, not a warning — the developer works through it before raising the migration PR.
Dry-run is the safe default; --apply must be given explicitly.
3.2 Apply mode
Section titled “3.2 Apply mode”bun scripts/codemods/wrap-define-route.ts --applybun scripts/codemods/wrap-define-route.ts --apply --scope app/api/adminWrites the handler rewrites to disk. Each rewritten file is left in a modified
state in the working tree; the developer reviews the diff with git diff before
committing.
Apply mode never touches MANUAL or NEEDS-REVIEW routes. Those routes are
written to codemod-needs-manual.json only.
4. Idempotency contract
Section titled “4. Idempotency contract”Re-running the codemod on an already-wrapped route is a no-op. The codemod
detects the defineRoute(...) wrapper at the top level of the export and
skips the file. Output:
api/items/[id]/route.ts — SKIPPED (already wrapped)This guarantee means the codemod can be run multiple times as routes are
progressively migrated, and --apply on a partially-migrated codebase is safe.
5. Diff preview format
Section titled “5. Diff preview format”Dry-run output for each MECHANISABLE route:
[TRANSFORM] api/intelligence/workspaces/route.ts Method: GET Shape: AUTH_PLAIN Schema: IntelligenceWorkspaceListResponse (from type-drift-baseline.json) --- before + import { defineRoute } from '@/lib/api/define-route'; import { getAuthorisedClient, authFailureResponse } from '@/lib/auth'; ... - export async function GET() { + export const GET = defineRoute(IntelligenceWorkspaceListResponse, async () => { const auth = await getAuthorisedClient(['admin', 'editor']); if (!auth.success) return authFailureResponse(auth); ... return NextResponse.json(payload); - } + });For routes where the ResponseSchema cannot be inferred, the placeholder is shown explicitly:
[TRANSFORM] api/items/route.ts Method: GET Shape: AUTH_PLAIN Schema: z.unknown() [PLACEHOLDER — author schema before committing] ...6. Failure modes per route shape
Section titled “6. Failure modes per route shape”6.1 MANUAL shapes — codemod skips
Section titled “6.1 MANUAL shapes — codemod skips”The following route shapes are emitted to codemod-needs-manual.json and
skipped during --apply:
| Shape | Count | Reason codemod cannot handle |
|---|---|---|
CRON | 9 | Uses createServiceClient() + cron-secret validation. No user context; defineRoute() presupposes an authenticated handler. Cron routes return plain status objects, not typed response payloads. |
NAKED_NO_AUTH | 6 | No getAuthorisedClient wrapper. Public routes (/api/health, RSS feeds, OAuth flow, plugin download) have fundamentally different contracts. |
MCP | 1 | Protocol handler (WebStandardStreamableHTTPServerTransport). Not a data-API route. |
Total MANUAL: 16 routes.
6.2 NEEDS-REVIEW shapes — codemod wraps, human confirms
Section titled “6.2 NEEDS-REVIEW shapes — codemod wraps, human confirms”The following shapes are processed in dry-run and apply modes, but the output
is flagged in codemod-needs-manual.json as requiring human review before
the PR is raised:
| Shape | Count | Review required |
|---|---|---|
MULTI_PARAM_BODY | 19 | Codemod wraps each exported HTTP method individually. Each method needs its own ResponseSchema; the developer must confirm schema identity or author it. |
MULTI_BODY | 17 | Same as above (no path params). |
MULTI_PARAM | 4 | Same as above (no body). |
withRequestContext sub-variant | 7 | Codemod preserves the outer withRequestContext() wrapper; developer must confirm the resulting double-wrapping composes correctly. |
Total NEEDS-REVIEW: 40 routes (some overlap between multi-method and
withRequestContext).
6.3 ResponseSchema inference failures
Section titled “6.3 ResponseSchema inference failures”For MECHANISABLE routes where no schema can be inferred (see TECH.md §3 for inference strategy), the codemod:
- Inserts a
z.unknown()placeholder as theResponseSchemaargument. - Adds a
// TODO(OPS-T1): author ResponseSchemacomment on the preceding line. - Emits the route to
codemod-needs-manual.jsonunder aneeds-schemareason code.
The developer must replace z.unknown() with a real Zod schema before the
migration PR is reviewed and merged. Routes with z.unknown() that reach CI
will be caught by type-drift-detect --ci if the route is in the
fetcher-only baseline (§7).
7. Verifier sub-section — ast-dataflow type-drift-detect
Section titled “7. Verifier sub-section — ast-dataflow type-drift-detect”lib/ast-dataflow/queries/type-drift-detect.ts is the post-migration
regression gate. After the migration PR lands:
bun run ast-dataflow type-drift-detect --ciThis runs the type-drift detector in CI mode, diffing the current route corpus
against docs/generated/type-drift-baseline.json. It fails the build if any
new fetcher-only interface appears (i.e. a newly migrated route introduced a
new client-side type without a matching handler annotation).
The expected post-migration outcome:
- Routes successfully wrapped with a real
ResponseSchemadrop out of thefetcher-onlybucket — those entries are removed from the baseline. - Routes wrapped with
z.unknown()placeholder remainfetcher-onlyuntil the schema is authored. - The baseline must be explicitly updated in the migration PR to reflect the closed gaps; CI enforces that the count does not grow.
Workflow:
- Run codemod in dry-run mode → review
codemod-dry-run.md. - Author missing
ResponseSchemaobjects for all placeholder routes. - Run codemod with
--apply. - Run
bun run ast-dataflow type-drift-detect --prettyto see the updated drift picture. - Update
docs/generated/type-drift-baseline.jsonto reflect closed gaps. - Run full test suite +
bun lint. - Raise migration PR; CI gate
type-drift-paritymust pass.
8. Acceptance criteria
Section titled “8. Acceptance criteria”Option-4 amendment (S262, Liam-ratified — supersedes the S11 framing above). The S262 type-safety research (
investigations/type-safety-strategy-research-S262.md§7) re-scoped OPS-T1 from a compile-time mechanical codemod to a runtime strict-validation deliverable. The acceptance criteria below are reframed accordingly. The per-AC saga that forced this — defects B1–B4 plus the permissive-schema false-confidence finding — is recorded indocs/continuation-prompts/s262-worker-reports/id32-final-report.yaml. The codemod mechanics ACs (AC-1, AC-2, AC-3, AC-4, AC-6, AC-7) are SOUND and are kept verbatim in intent; the contract ACs (AC-5, AC-8, AC-9, AC-10) are reinterpreted around the new model. Three new invariants (INV-S, INV-PT, INV-FP) make the previously-implicit “strict enough to catch drift” requirement EXPLICIT and TESTABLE — that exact ambiguity is what silently resolved the wrong way (permissive schemas = false confidence) in S262.
8.0 Behaviour invariants (the Option-4 model, made testable)
Section titled “8.0 Behaviour invariants (the Option-4 model, made testable)”These are the load-bearing testable invariants the Checker verifies. They are numbered separately from AC-N so the AC↔proposed-change mapping in TECH.md stays one-to-one.
| ID | Invariant (testable) |
|---|---|
| INV-PT | defineRoute is a PASS-THROUGH validator, not a payload-returner. Given a handler that returns a Response/NextResponse, the wrapper passes the response’s status, headers, redirects, and streaming/non-JSON bodies through UNCHANGED, and validates ONLY the parsed JSON body of 2xx application/json responses against the schema. A handler returning a raw (non-Response) payload is JSON-wrapped (polymorphic support for both shapes). Test: a handler returning NextResponse.json({error},{status:401}) yields a 401 with that exact body unchanged (NOT a re-wrapped 200, NOT a 500); a handler returning a 200 JSON body that matches the schema yields that body unchanged; a 3xx redirect, a 204, and a text/event-stream response each pass through with status + headers intact and NO schema parse attempted. |
| INV-FP | Failure policy is environment-split: FAIL-OPEN in production, LOUD in dev + CI + test. When a 2xx JSON body fails schema.safeParse: in production (process.env.NODE_ENV === 'production') the wrapper LOGS the drift (via the canonical lib/logger) and returns the original, unmodified response (fail-open — a drift defect must never take down a prod endpoint); in development, test, and CI (NODE_ENV !== 'production', OR process.env.CI set) the wrapper FAILS LOUD (throws / surfaces a hard error) so the drift is caught before it ships. Test: identical drifting payload yields the original 2xx response + a logged drift record under NODE_ENV=production, and a thrown/failing error under NODE_ENV=test. This policy is Liam-CONFIRMED (S262 OQ resolution). |
| INV-S | Strict-enough-to-catch-drift: blanket .loose() / z.unknown() is BANNED in the generated response-schema block, with a single narrow exception. A generated response schema member may be z.unknown() ONLY where the source interface property is genuinely un-narrowable (e.g. a Json-typed/opaque DB column or an external generic the type checker resolves to any/unknown), and a z.object may carry .loose() ONLY where the source interface declares a real index signature ([k: string]: T). Everywhere else, the generated object is the zod-4 default z.object({...}) (which STRIPS additive wire fields but REJECTS a renamed/removed/retyped declared field — empirically verified zod 4.4.3 semantics). Test: a static check over the BEGIN/END generated: R-WP17 block in lib/validation/schemas.ts asserts zero .loose() and zero z.unknown() EXCEPT entries on an allow-list each justified by a cited source-interface index-signature or opaque-Json property; and a runtime check that a schema with a renamed declared field (item_count → itemCount) fails safeParse where the pre-amendment .loose() schema passed it. |
8.1 Acceptance criteria (reframed)
Section titled “8.1 Acceptance criteria (reframed)”| ID | Criterion | Status / change |
|---|---|---|
| AC-1 | bun scripts/codemods/wrap-define-route.ts (dry-run, no flags) runs to completion on the full live app/api/**/route.ts corpus (~195 routes; the 193 inventory integer is a point-in-time snapshot — assert behaviour, not the magic number) without error or uncaught exception. | KEPT (mechanics sound; corpus count de-magic’d). |
| AC-2 | --apply mode rewrites exactly the MECHANISABLE (TRANSFORM) routes and leaves the NEEDS-REVIEW / MANUAL routes untouched on disk. Verified as the exhaustive + disjoint partition invariant (TRANSFORM ∪ NEEDS_REVIEW ∪ MANUAL ∪ SKIPPED = corpus), not a hard-coded count. | KEPT. |
| AC-3 | Idempotency: running --apply twice produces no further file changes (second run reports all routes SKIPPED). | KEPT. |
| AC-4 | codemod-needs-manual.json is produced in every run (dry-run and apply); it lists all MANUAL routes plus any NEEDS-REVIEW routes with their reason codes. | KEPT. |
| AC-5 | Routes from the R-WP17 37-interface baseline that are in the MECHANISABLE set bind a real, STRICT ResponseSchema (the ${interface}Schema constant), not z.unknown(). Already satisfied — 54 real binds shipped (32.20/32.21/32.22, per the final report) — but the bound schemas must additionally satisfy INV-S (strict, not the permissive .loose() output of 32.20). | REINTERPRETED + tightened. Binding done; strictness is the residual (Subtask {32.26}). |
| AC-6 | For routes that still fall back to z.unknown() (no co-located schema), the // TODO(OPS-T1): author ResponseSchema comment is present. | KEPT. |
| AC-7 | withRequestContext routes are wrapped such that withRequestContext remains the outermost wrapper: export const METHOD = withRequestContext(defineRoute(Schema, async (...) => { ... })). The wrapper must EXACT-match the withRequestContext callee (not the withRequestContextBare synonym — the B1 substring false-positive). | KEPT (B1 root cause folded into the criterion text). |
| AC-8 | After --apply, bun run test (route unit suite) passes UNDER THE PASS-THROUGH WRAPPER — i.e. the migrated handlers’ existing NextResponse.json(..., { status }) error returns pass through unchanged, so the ~178 routes that return NextResponse inline do NOT regress. (This is the defect-B4 reinterpretation: AC-8 is satisfied by INV-PT pass-through behaviour, NOT by a payload-returning rewrite that double-wraps and fails 1676/2075 tests.) | REINTERPRETED around INV-PT — the crux of Option-4. |
| AC-9 | After --apply, the migrated app/api/**/route.ts files introduce no new lint errors (delta after ≤ before, OQ-3 resolution) AND pass a no-undef/tsc --noEmit check proving every emitted defineRoute(...) has its z + schema imports resolved. AC-9 must NOT pass vacuously: ESLint is blind to no-undef on TypeScript, so it did NOT catch defect B3 (130+ routes ReferenceError at load from missing imports). A lint-delta alone is insufficient; the tsc/no-undef pairing is mandatory. | REINTERPRETED — de-vacuum’d. Lint-delta PLUS a tsc/no-undef gate. The Checker specifically verifies AC-9 is non-vacuous. |
| AC-10 | bun run ast-dataflow type-drift-detect --ci passes after the baseline is updated to reflect the closed gaps (AC-5 routes removed from fetcher-only). | KEPT. |
Continuous real-corpus probe (PLAN mandate — see PLAN §0 / TECH §11). The real-corpus acceptance probe (apply-against-temp-copy + run the route unit suite) MUST run from the FIRST implementation slice onward, not as a final gate. In S262 it ran LAST, so all four defects (B1–B4) surfaced only at the end. Every implementation Subtask’s
detailsrepeats this mandate.
9. Out of scope
Section titled “9. Out of scope”The— NO LONGER out of scope under Option-4.defineRoute()wrapper function itselflib/api/define-route.tsEXISTS (shipped Subtask 32.5) and is REDESIGNED to the pass-through contract by Subtask {32.25}. The S11 line (“must be authored separately, before the codemod is run”) is superseded.- CRON, NAKED_NO_AUTH, and MCP route migration — excluded by design (see §6.1).
- Python pipeline or SQL migration changes.
- Automatic ResponseSchema authoring for the non-baseline routes — requires human domain knowledge.
- The DB-layer warp-analog (Supabase-types-parity CI +
MergeDeepJSONB overrides) is a SEPARATE Task ID-47, NOT part of OPS-T1. (S262 research §7 step 1; Liam-confirmed.) Do not pull it into the OPS-T1 implementation slices.
10. Related documents
Section titled “10. Related documents”ops-t1-codemod/TECH.md— ts-morph implementation strategy, test fixtures, CLI design.ops-t1-codemod/route-shape-inventory.md— empirical route shape counts.type-safety-pipeline/decision-OPS-T1.md— RATIFIED-S10 option (c) + S11 brief.type-safety-pipeline/PRODUCT.md— D-19 (OPS-T1 gap definition).lib/ast-dataflow/queries/type-drift-detect.ts— verifier implementation.docs/generated/type-drift-baseline.json— 37-interface baseline.investigations/S10-programmatic-migration-feasibility.md§2 W1 — detailed mechanisation estimate and tool-chain gap analysis.