Skip to content

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-through defineRoute contract + schema-strictness rules live there).


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.


ActorWhenContext
Developer (Liam or contributor)Once, at start of main-track Phase 1 canonical-pipeline migrationAligns with the project_idworkspace_id and other Phase 1 workstreams
Developer (targeted repair)Any time a new subsystem’s routes need wrappingCan target a single directory with --scope
CI gate (post-merge verification)After the migration PR landstype-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.


Terminal window
bun scripts/codemods/wrap-define-route.ts
bun scripts/codemods/wrap-define-route.ts --scope app/api/intelligence/workspaces

No 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.

Terminal window
bun scripts/codemods/wrap-define-route.ts --apply
bun scripts/codemods/wrap-define-route.ts --apply --scope app/api/admin

Writes 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.


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.


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]
...

The following route shapes are emitted to codemod-needs-manual.json and skipped during --apply:

ShapeCountReason codemod cannot handle
CRON9Uses createServiceClient() + cron-secret validation. No user context; defineRoute() presupposes an authenticated handler. Cron routes return plain status objects, not typed response payloads.
NAKED_NO_AUTH6No getAuthorisedClient wrapper. Public routes (/api/health, RSS feeds, OAuth flow, plugin download) have fundamentally different contracts.
MCP1Protocol 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:

ShapeCountReview required
MULTI_PARAM_BODY19Codemod wraps each exported HTTP method individually. Each method needs its own ResponseSchema; the developer must confirm schema identity or author it.
MULTI_BODY17Same as above (no path params).
MULTI_PARAM4Same as above (no body).
withRequestContext sub-variant7Codemod 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).

For MECHANISABLE routes where no schema can be inferred (see TECH.md §3 for inference strategy), the codemod:

  1. Inserts a z.unknown() placeholder as the ResponseSchema argument.
  2. Adds a // TODO(OPS-T1): author ResponseSchema comment on the preceding line.
  3. Emits the route to codemod-needs-manual.json under a needs-schema reason 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:

Terminal window
bun run ast-dataflow type-drift-detect --ci

This 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 ResponseSchema drop out of the fetcher-only bucket — those entries are removed from the baseline.
  • Routes wrapped with z.unknown() placeholder remain fetcher-only until 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:

  1. Run codemod in dry-run mode → review codemod-dry-run.md.
  2. Author missing ResponseSchema objects for all placeholder routes.
  3. Run codemod with --apply.
  4. Run bun run ast-dataflow type-drift-detect --pretty to see the updated drift picture.
  5. Update docs/generated/type-drift-baseline.json to reflect closed gaps.
  6. Run full test suite + bun lint.
  7. Raise migration PR; CI gate type-drift-parity must pass.

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 in docs/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.

IDInvariant (testable)
INV-PTdefineRoute 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-FPFailure 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-SStrict-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_countitemCount) fails safeParse where the pre-amendment .loose() schema passed it.
IDCriterionStatus / change
AC-1bun 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-3Idempotency: running --apply twice produces no further file changes (second run reports all routes SKIPPED).KEPT.
AC-4codemod-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-5Routes 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-6For routes that still fall back to z.unknown() (no co-located schema), the // TODO(OPS-T1): author ResponseSchema comment is present.KEPT.
AC-7withRequestContext 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-8After --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-9After --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-10bun 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 details repeats this mandate.


  • The defineRoute() wrapper function itselfNO LONGER out of scope under Option-4. lib/api/define-route.ts EXISTS (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 + MergeDeep JSONB 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.

  • 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.