Skip to content

OPS-T1 codemod — wrap-define-route — Technical spec

OPS-T1 codemod — wrap-define-route — Technical spec

Section titled “OPS-T1 codemod — wrap-define-route — Technical spec”

Status: AMENDED-S265 (Option-4, Liam-ratified S262). NEW: §2.4a (pass-through defineRoute contract), §3.1a (schema-strictness rules for generate-response-schemas.ts), §11 (continuous real-corpus probe), §12 (empirical verification record). §8.2 corrected (Next.js 15 → 16.2.6). §§1–3 codemod mechanics otherwise retained. Source: decision-OPS-T1.md §Sign-off (RATIFIED-S10); investigations/type-safety-strategy-research-S262.md §§2.5, 7, 8 — the Option-4 governing decision; investigations/S10-programmatic-migration-feasibility.md §2 W1; ops-t1-codemod/PRODUCT.md (user-facing behaviour — §8 INV-PT/INV-FP/INV-S are the contract this TECH implements); ops-t1-codemod/route-shape-inventory.md. Sibling: ops-t1-codemod/PRODUCT.md (product behaviour).


The codemod is a standalone TypeScript script at scripts/codemods/wrap-define-route.ts, invocable via:

Terminal window
bun scripts/codemods/wrap-define-route.ts [--apply] [--scope <glob>]

It does not import from the ast-dataflow query library. It uses ts-morph directly against the KH tsconfig.json. The codemod is a write path; ast-dataflow remains read-only (PRODUCT.md non-goal §6: “not an autofix tool”).

Step 1: Load ts-morph Project from tsconfig.json
Step 2: Enumerate source files matching app/api/**/route.ts
Step 3: For each file → classify shape (MECHANISABLE / NEEDS-REVIEW / MANUAL)
Step 4: For each MECHANISABLE file:
a. Infer ResponseSchema (three-path strategy — see §3)
b. Emit transformation plan (dry-run) or apply rewrite (--apply)
Step 5: Emit codemod-dry-run.md + codemod-needs-manual.json
Step 6: Exit 0 (MANUAL routes do not cause failure)

import { Project } from 'ts-morph';
const project = new Project({
tsConfigFilePath: 'tsconfig.json',
// Do not add source files not in tsconfig — prevents accidentally editing
// test fixtures or the codemod itself.
skipAddingFilesFromTsConfig: false,
});

All route files are already included via tsconfig.json’s include glob (app/**/*.ts). No manual addSourceFilesAtPaths call is needed.

const routeFiles = project
.getSourceFiles()
.filter((sf) => sf.getFilePath().match(/app\/api\/.*\/route\.ts$/));

When --scope is provided, further filter by sf.getFilePath().includes(scope).

Classification is performed by inspecting the SourceFile AST before any edit. The classifier mirrors the grep-based heuristics from the Phase 1 inventory, but uses type-checker-resolved ts-morph APIs for accuracy.

function classifyRoute(sf: SourceFile): RouteShape {
const path = sf.getFilePath();
if (path.includes('/cron/')) return 'CRON';
if (path.includes('/mcp/')) return 'MCP';
const hasAuth = sf.getImportDeclarations().some((d) =>
d.getModuleSpecifierValue().includes('@/lib/auth') &&
(d.getNamedImports().some((n) =>
['getAuthorisedClient', 'getAuthenticatedClient'].includes(n.getName())
))
);
if (!hasAuth) return 'NAKED_NO_AUTH';
const methods = getExportedMethods(sf); // GET, POST, PATCH, etc.
const isParameterised = path.includes('[');
const hasBody = sf.getFullText().includes('request.json()') ||
sf.getFullText().includes('parseBody(');
const hasWithRequestContext = sf.getFullText().includes('withRequestContext');
if (methods.length > 1) {
const variant = isParameterised
? (hasBody ? 'MULTI_PARAM_BODY' : 'MULTI_PARAM')
: (hasBody ? 'MULTI_BODY' : 'MULTI_BODY');
return hasWithRequestContext ? `${variant}+WRC` : variant;
}
if (isParameterised && hasBody) return hasWithRequestContext ? 'PARAM_BODY+WRC' : 'PARAM_BODY';
if (isParameterised) return 'PARAM';
if (hasBody) return 'BODY_VALIDATED';
return hasWithRequestContext ? 'AUTH_PLAIN+WRC' : 'AUTH_PLAIN';
}

getExportedMethods(sf) collects both export async function METHOD(...) and export const METHOD = withRequestContext(...) patterns.

For each MECHANISABLE route:

Step A — Add defineRoute import

const existing = sf.getImportDeclaration(
(d) => d.getModuleSpecifierValue() === '@/lib/api/define-route'
);
if (!existing) {
sf.addImportDeclaration({
moduleSpecifier: '@/lib/api/define-route',
namedImports: ['defineRoute'],
});
}

Step B — Rewrite each exported handler function

Target node: ExportedDeclaration of kind FunctionDeclaration with name in {GET, POST, PUT, PATCH, DELETE}.

// Before:
// export async function GET(request: NextRequest) { ... }
// After:
// export const GET = defineRoute(ResponseSchema, async (request: NextRequest) => { ... });

The FunctionDeclaration is replaced with a VariableStatement:

const fnDecl = sf.getFunctionOrThrow('GET');
const body = fnDecl.getBodyOrThrow().getText();
const params = fnDecl.getParameters().map((p) => p.getText()).join(', ');
const schema = inferSchema(sf, 'GET', project); // §3
fnDecl.replaceWithText(
`export const GET = defineRoute(${schema}, async (${params}) => ${body});`
);

For withRequestContext routes, the outer wrapper is preserved:

// Before:
// export const POST = withRequestContext(async (request: NextRequest) => { ... });
// After:
// export const POST = withRequestContext(
// defineRoute(ResponseSchema, async (request: NextRequest) => { ... })
// );

Step C — Save the file

if (applyMode) {
await sf.save();
}

In dry-run mode, sf.save() is skipped; the proposed text diff is computed via sf.print() vs the original text.


2.4a The defineRoute PASS-THROUGH contract (Option-4 — supersedes the payload-returning contract)

Section titled “2.4a The defineRoute PASS-THROUGH contract (Option-4 — supersedes the payload-returning contract)”

This section is the governing implementation contract for lib/api/define-route.ts under Option-4. It implements PRODUCT §8 INV-PT (pass-through) and INV-FP (fail-open-prod / loud-dev+CI+test). It replaces the S11 assumption that handlers return a raw z.infer<S> payload — defect B4 proved 178/193 (now ~178/195) handlers return NextResponse inline (authFailureResponse(auth) → 401, inline { error } → 500, etc.), so a payload-returning contract double-wraps and regresses 1676/2075 route tests. The fix is to make defineRoute a validating pass-through.

defineRoute(schema, handler) calls the handler, then branches on the handler’s return value:

  1. Handler returned a Response/NextResponse (the majority path).
    • Clone the response (res.clone()) so the body can be read without consuming the stream the caller will send. Verified Next.js 16.2.6 / undici semantics — see §12.
    • Validate ONLY when ALL of: status is 2xx AND the content-type header starts with application/json AND the cloned body parses as JSON. Read the body from the clone, never the original.
    • For every other response — non-2xx (4xx/5xx error envelopes from authFailureResponse etc.), 3xx redirect (Location set / res.redirected), 204 / 205 / empty body, non-JSON content-type, streaming (text/event-stream, ReadableStream body, chunked) — pass the ORIGINAL response through UNCHANGED, with NO clone-read and NO schema parse. (Status, headers, and body are the handler’s; the wrapper is transparent.)
  2. Handler returned a raw (non-Response) payload (the minority path — naturally-conforming new code). Validate the payload against the schema (existing safeParse path), then NextResponse.json(parsed.data) on success, or apply the failure policy (§2.4a.2) on failure. This keeps the wrapper polymorphic: both return styles are supported, so the codemod never has to perform the throw-for-errors semantic rewrite that forked S262.

Detection of “is a Response” uses instanceof Response (NextResponse extends the WHATWG Response, so NextResponse instanceof Response is true — verify in the 32.5 test). Do NOT rely on duck-typing a .json method.

2.4a.2 The failure policy (INV-FP) — fail-open-prod / loud-dev+CI+test

Section titled “2.4a.2 The failure policy (INV-FP) — fail-open-prod / loud-dev+CI+test”

When a 2xx JSON body (case 1) OR a raw payload (case 2) fails schema.safeParse:

// Environment split — Liam-CONFIRMED (S262 OQ resolution).
// Reuse the corpus-canonical env predicate (lib/logger/index.ts:61-62 uses
// the same NODE_ENV checks); CI is treated as a loud environment.
// LOUD when NODE_ENV !== 'production' OR process.env.CI is set — this exactly
// matches PRODUCT §8 INV-FP, and crucially covers the spec-valid case where a
// CI runner sets NODE_ENV=production but CI=true (that run must FAIL LOUD, not
// fail-open). Fail-open (prod) therefore fires ONLY when in production AND NOT
// in CI.
const isLoud = process.env.NODE_ENV !== 'production' || !!process.env.CI;
const isProd = !isLoud; // production AND not CI → the only fail-open environment
if (parsed.success) {
/* pass-through / wrap as in §2.4a.1 */
} else if (isLoud) {
// LOUD: surface the drift so it is caught before it ships. Throw (the
// route-unit test / CI run fails) — this is the mock-proof net working.
throw new ResponseSchemaValidationError(routeId, parsed.error.issues);
} else {
// FAIL-OPEN (prod): a drift defect must NEVER take down a live endpoint.
// Log the drift via the canonical logger (lib/logger), then return the
// ORIGINAL, unmodified response so the user is unaffected.
logger.error(
{ route: routeId, issues: parsed.error.issues },
'response_schema_validation_failed (fail-open: returning original response)',
);
return originalResponse; // case 1: the un-cloned original. case 2: NextResponse.json(payload).
}

Rationale (S262 §6 mock-proof table): the LOUD path in dev/CI/test is what makes defineRoute a mock-proof net — it runs on the real wire payload in CI regardless of test quality, so a “test built to pass” cannot hide drift. The PROD fail-open path keeps that net from becoming a new outage vector.

Note on the old 500-envelope behaviour. The S11 contract returned a { error: 'response_schema_validation_failed', issues } 500 on any failure (define-route.ts:97-110). Under Option-4 that 500-on-drift is dropped in prod (fail-open) and escalated to a throw in dev/CI/test (louder than a 500). The existing 32.5 test “returns a 500 envelope when the handler payload does not match the schema” must be reworked to assert the new split (throw under NODE_ENV=test; original-response + logged drift under NODE_ENV=production) — see Subtask {32.25}.

2.4a.3 The compile-time clause — keep as a non-binding bonus

Section titled “2.4a.3 The compile-time clause — keep as a non-binding bonus”

The S11 wrapper constrained handler: (...) => Promise<z.infer<S>>. Under the pass-through contract the handler’s return type is Promise<Response | z.infer<S>> (the union of the two supported styles). The z.infer<S> arm is retained as a free bonus for the handful of routes that genuinely return a raw payload — it is NOT a guarantee forced on the ~178 NextResponse-returning routes (S262 OQ-2 disposition: keep-as-bonus, do not imply a general guarantee). The codemod does NOT rewrite handlers to satisfy the raw-payload arm.

The S11 define-route.ts imports z as import type { z } from 'zod' (type-only) and NextResponse as a value. The pass-through redesign:

  • keeps NextResponse as a value import (used for NextResponse.json on the raw-payload arm);
  • adds a value import for the canonical logger (@/lib/logger — direct file import per the no-barrel-re-exports rule; resolve the exact path, the logger is at lib/logger/index.ts);
  • the z type-only import is sufficient (the wrapper calls schema.safeParse via the S extends z.ZodTypeAny generic; it does not construct zod values). Confirm in the 32.5 rework that no value-level z use creeps in.

Inferring the Zod ResponseSchema is the hardest part of the migration. Three candidate sources exist; the codemod evaluates them at runtime in the canonical priority order defined below (“Canonical chain order”), using the first source that yields a schema for each handler.

Option-4 note (binding requirement for §3.1a below). Source A binds the ${interface}Schema constant produced by generate-response-schemas.ts. As shipped (Subtask 32.20) that generator errs PERMISSIVE — its output is the 1,020-line block at lib/validation/schemas.ts:2314-3334 containing 84 .loose() + 10 z.unknown() (empirically confirmed, §12). A .loose() object with z.unknown() members accepts almost any payload, so defineRoute(XSchema, …) would catch a grossly-malformed response but NOT a renamed / removed / retyped field — i.e. false confidence (S262 §2.5b). The strictness rules in §3.1a are the high-leverage fix and the contract for Subtask {32.26}.

3.1a Schema-strictness rules for generate-response-schemas.ts (INV-S)

Section titled “3.1a Schema-strictness rules for generate-response-schemas.ts (INV-S)”

The generator must derive strictness from the REAL source interfaces, not err blanket-permissive. The rule set (each emitted member is governed by the FIRST matching rule):

Source-interface propertyOLD (permissive, banned)NEW (strict, required)
Plain object literal with named propsz.object({...}).loose()z.object({...})zod-4 default; NO .loose(). The default STRIPS additive wire fields but REJECTS a renamed/removed/retyped declared field (empirically verified, §12). This is the “strict enough to catch drift, lenient on additive fields” sweet spot — and is why z.strictObject is NOT used (it would 500 on a legitimately-added field).
Property whose source type is a string-literal union ('a' | 'b')sometimes collapsed to z.unknown() (e.g. content_type: z.unknown())z.enum(['a','b']) / z.literal('a') — the generator already maps these in typeToZod; the bug is members reached via an un-walked path. Resolve them.
Property whose source type is a named interface/alias with nested shapez.unknown() where the ref wasn’t walkedrecurse into the referenced type (the extends-flattening walk already exists; extend it to followed type-references), emitting a nested default z.object({...}).
Property genuinely typed Json / opaque-DB / external generic resolving to any/unknownz.unknown()z.unknown()PERMITTED, but only here. Must be recorded on the allow-list (§3.1a allow-list) with the cited source property.
T | null / T? optional members.nullable() / .optional()unchanged — these are correct.
Object whose source declares a real index signature [k: string]: Tn/a (rare)z.looseObject({...}) / .loose()PERMITTED, but only here. Record on the allow-list with the cited index signature. Empirically, zero of the 37 R-WP17 source interfaces have a genuine string index signature (only types/jsdom.d.ts, a test-env declaration, does — §12), so this exception is expected to fire essentially never.

Allow-list mechanism. The generator emits, alongside the schema block, a machine-checkable manifest (e.g. a // ALLOW: z.unknown @ <Interface>.<prop> — <Json|external-generic> comment per permitted exception, or a sibling JSON). The INV-S static check (Subtask {32.27} / a guard test) parses the BEGIN/END generated block and asserts: every .loose() and every z.unknown() has a matching allow-list entry citing a real index-signature or opaque-Json/external source property. A new un-justified .loose()/z.unknown() fails the check.

Strictness surfaces real drift (S262 OQ-1, Liam-accepted). Tightening may reveal that a route ALREADY returns a wrong shape — under INV-FP that is a LOUD failure in dev/CI/test (the net working) and a logged-but-served response in prod (no user impact). The {32.26} rollout must run the continuous probe (§11) so any such pre-existing drift is surfaced as it is tightened, not at a final gate. The zod-4 default-object choice (strip-additive, reject-declared) deliberately minimises false positives from routes that legitimately add fields.

zod-4 API note. This corpus is on zod ^4.4.3 (§12). In zod 4: .passthrough().loose() (or z.looseObject); .strict()z.strictObject (or .strict()); the bare z.object({...}) default strips unknown keys (it does NOT reject them — that is z.strictObject). The rules above are written against these zod-4 semantics; do not port zod-3 method names.

Source A — type-drift-baseline.json (highest confidence, ~37 routes)

Section titled “Source A — type-drift-baseline.json (highest confidence, ~37 routes)”

docs/generated/type-drift-baseline.json contains the 37 fetcher-only interfaces identified by R-WP17. Each entry has the form:

{
"interface": "IntelligenceWorkspaceListResponse",
"declaredAt": { "file": "types/intelligence.ts", "line": 42 },
"bucket": "fetcher-only"
}

For routes in the baseline, the interface name is known. The codemod looks up the Zod schema by name convention:

  1. Check lib/validation/schemas.ts for ${interfaceName}Schema or ${interfaceName}ZodSchema.
  2. If found, use directly: defineRoute(IntelligenceWorkspaceListResponseSchema, ...).
  3. If not found, emit z.unknown() placeholder with a NEEDS-REVIEW note in codemod-needs-manual.json.

Trade-off: Source A has the highest accuracy because the interface-to-route mapping was validated by R-WP17’s heuristic URL matcher. However, it covers only 37 of 193 routes. Zod schema naming conventions are not enforced at present, so step 2 may fail for interfaces that lack a co-located Schema constant.

Source B — Existing return-type annotation on the handler (medium confidence)

Section titled “Source B — Existing return-type annotation on the handler (medium confidence)”

If the handler already carries a return-type annotation of the form Promise<NextResponse<X>>:

export async function GET(): Promise<NextResponse<WorkspaceListItem[]>> { ... }

the codemod extracts WorkspaceListItem[] and uses it as the schema:

const returnType = fnDecl.getReturnTypeNode();
if (returnType) {
const inner = extractNextResponseTypeArg(returnType);
if (inner) return zodSchemaFor(inner, project); // resolves to z.array(WorkspaceListItemSchema) etc.
}

zodSchemaFor attempts to resolve the type reference to a co-located Zod schema using the same name-convention lookup as Source A. If the lookup fails, it falls back to z.unknown().

Trade-off: Source B requires the route to have an explicit return-type annotation — a minority of the current codebase (the 2 route-only routes from R-WP17 plus any recently annotated handlers). It is not a new annotation mechanism; it simply reads what is already there.

Source C — Handler return-statement walk (lowest confidence, fallback only)

Section titled “Source C — Handler return-statement walk (lowest confidence, fallback only)”

If neither Source A nor B applies, the codemod performs a shallow walk of the handler’s return statements looking for NextResponse.json(payload) calls, then type-checks payload via ts-morph’s getType():

const returnStmts = fnDecl.getBody()?.getDescendantsOfKind(SyntaxKind.ReturnStatement);
for (const ret of returnStmts ?? []) {
const expr = ret.getExpression();
if (isNextResponseJsonCall(expr)) {
const arg = expr.getArguments()[0];
const argType = arg.getType();
const schema = zodSchemaForType(argType, project);
if (schema) return schema;
}
}
return 'z.unknown()';

zodSchemaForType maps well-known type patterns (primitive arrays, interface references with co-located Schema constants) to their Zod equivalents. For complex anonymous types (e.g. { data: X[]; total: number }) it returns z.unknown().

Trade-off: Source C is the broadest net but produces many z.unknown() results because most return types are anonymous or inlined. It is included as a last resort to reduce the manual count, not to guarantee schema accuracy.

For the implementation session, the recommended approach is:

  1. Implement Source A first. It covers the 37 baseline routes with high confidence and provides the clearest test cases.
  2. Implement Source B second. It costs little (just reading an existing annotation) and may close a handful of additional routes.
  3. Defer Source C to a post-initial pass or omit entirely if the z.unknown() fallback is acceptable for the implementation timeline.

The PRODUCT.md acceptance criteria (AC-5) require only that Source A routes receive real schemas; Sources B and C are nice-to-have.

Canonical chain order (runtime precedence)

Section titled “Canonical chain order (runtime precedence)”

inferSchema() evaluates sources at runtime in the order Source B → Source A: an explicit Promise<NextResponse<X>> return-type annotation (Source B) is authoritative — the developer’s stated type beats Source A’s heuristic URL matcher — so Source B is tried first, and Source A runs only when Source B returns null (no annotation present). Both land on the shared z.unknown() + NEEDS-REVIEW fall-back when their name-convention lookup fails. Source C is out of scope for this PLAN cycle (deferred).

This runtime precedence (B → A) is deliberately distinct from the implementation order in “Recommended inference ranking” above (build Source A first): build order optimises test-case clarity, runtime order optimises schema correctness. This B → A order is the canonical oracle for Subtask 32.16’s full-corpus acceptance gate, as shipped in Subtask 32.9 (scripts/codemods/wrap-define-route.ts inferSchema(), commit b4a04df6; docstring lines 433-465).


The test suite lives at __tests__/scripts/codemods/wrap-define-route.test.ts. One fixture file per shape variant, stored at __tests__/scripts/codemods/fixtures/wrap-define-route/.

Fixture fileShapeNotes
auth-plain.tsAUTH_PLAINSingle GET, getAuthorisedClient, no params, no body
auth-plain-with-wrc.tsAUTH_PLAIN+withRequestContextGET wrapped in withRequestContext
param-body.tsPARAM_BODYSingle POST, Promise<{ id: string }> params, parseBody()
body-validated.tsBODY_VALIDATEDSingle POST, no params, request.json() + parseBody()
param-only.tsPARAMSingle GET, params, no body
multi-param-body.tsMULTI_PARAM_BODYGET + PATCH + DELETE, params, body on PATCH
multi-body.tsMULTI_BODYGET + POST, no params, body on POST
multi-param.tsMULTI_PARAMGET + DELETE, params, no body
cron.tsCRONSingle POST, cron-secret auth — MANUAL expected
naked-no-auth.tsNAKED_NO_AUTHSingle GET, no auth call — MANUAL expected
mcp.tsMCPGET + POST + DELETE, MCP transport — MANUAL expected
already-wrapped.tsIdempotencyRoute already using defineRoute() — SKIPPED expected
with-schema-in-baseline.tsSource A inferenceRoute in type-drift-baseline.json baseline
with-return-type-annotation.tsSource B inferenceHandler has Promise<NextResponse<X>> annotation

Each fixture test verifies:

  1. Shape classification matches expected value.
  2. Dry-run output matches a snapshot.
  3. Apply mode produces the correct transformed source text (snapshots via expect(result).toMatchInlineSnapshot(...)).
  4. Already-wrapped fixture produces SKIPPED with no file change.
  5. MANUAL fixtures are skipped with correct reason codes in codemod-needs-manual.json.

scripts/codemods/wrap-define-route.ts
const args = parseArgs(process.argv.slice(2), {
boolean: ['apply', 'help'],
string: ['scope'],
default: { apply: false },
});
if (args.help) {
console.log(`
wrap-define-route — OPS-T1 codemod for Knowledge Hub
Usage:
bun scripts/codemods/wrap-define-route.ts [options]
Options:
--apply Write changes to disk (default: dry-run only)
--scope <glob> Restrict to routes matching this path fragment
(e.g. 'app/api/intelligence')
--help Show this message
Output files (always written, even in dry-run):
docs/generated/codemod-dry-run.md Human-readable diff preview
docs/generated/codemod-needs-manual.json Structured MANUAL/NEEDS-REVIEW report
`);
process.exit(0);
}

Exit codes:

CodeMeaning
0Success (all MECHANISABLE routes processed; MANUAL routes in report)
1Fatal error (ts-morph Project load failure, file write permission error, etc.)

The codemod never exits non-zero merely because routes are MANUAL — that is the expected condition for 16 routes. Callers should check codemod-needs-manual.json for the list of routes requiring human attention.


Markdown report generated on every run. Sections:

  • Summary table — count by shape and mechanisability verdict.
  • Proposed transformations — per MECHANISABLE route: shape, inferred schema source (A/B/C/placeholder), diff preview.
  • NEEDS-REVIEW routes — list with reason per route.
  • MANUAL routes — list with reason per route.

6.2 docs/generated/codemod-needs-manual.json

Section titled “6.2 docs/generated/codemod-needs-manual.json”

Machine-readable JSONL (one object per route). Schema:

interface NeedsManualEntry {
route: string; // Relative path, e.g. "app/api/cron/process-queue/route.ts"
shape: string; // Shape code, e.g. "CRON"
reason: NeedsManualReason;
methods?: string[]; // For multi-method routes, the affected methods
}
type NeedsManualReason =
| 'CRON_AUTH_MODEL' // Cron-secret auth, not getAuthorisedClient
| 'NAKED_NO_AUTH' // No auth wrapper
| 'MCP_TRANSPORT' // MCP protocol handler
| 'MULTI_METHOD_SCHEMA' // Multiple methods need individual ResponseSchema confirmation
| 'WRC_COMPOSITION' // withRequestContext wrapper needs composition check
| 'NEEDS_SCHEMA'; // Schema cannot be inferred — z.unknown() placeholder inserted

7. Verifier sub-section — integration with type-drift-detect

Section titled “7. Verifier sub-section — integration with type-drift-detect”

The codemod’s output is verified by ast-dataflow’s type-drift-detect query at lib/ast-dataflow/queries/type-drift-detect.ts.

After --apply:

Terminal window
# Step 1 — run the detector (pretty mode for human review)
bun run ast-dataflow type-drift-detect --pretty
# Expected: routes wrapped with a real ResponseSchema drop from 'fetcher-only'
# Routes with z.unknown() placeholder remain 'fetcher-only'
# Step 2 — update the baseline to reflect closed gaps
# Edit docs/generated/type-drift-baseline.json manually:
# Remove rows for routes that now have annotations (the 37 baseline routes
# where Source A inference succeeded).
# Step 3 — run CI-mode gate
bun run ast-dataflow type-drift-detect --ci
# Must exit 0 (no new fetcher-only interfaces vs updated baseline)

After the migration PR:

MetricPre-migrationPost-migration target
fetcher-only interfaces37≤ 0 for Source-A routes; unchanged for non-baseline routes
enforced interfaces0≥ 37 (Source-A routes each gain a Promise<NextResponse<X>> annotation)
route-only interfaces2≤ 2 (these are annotation-only additions; must not regress)
unused interfaces21Unchanged (cleanup is a separate workstream)

If type-drift-detect --ci reports a new fetcher-only interface (one not in the pre-migration baseline), that is a regression introduced by the codemod and must be resolved before the migration PR is merged.

type-drift-detect is the only query in the ast-dataflow suite that specifically models the relationship between route return types and fetcher type parameters. It provides:

  • A before snapshot (the existing baseline).
  • An after snapshot (post-migration detector run).
  • A diff in CI mode that fails the build on regression.

It is not a simple TypeScript type check — it traverses the actual fetcher call sites and maps them to route files heuristically, which is necessary because TanStack Query fetchers are not statically typed to their endpoints. This makes type-drift-detect the most appropriate post-migration gate, complementing bun run test and bun lint.


withRequestContext from @/lib/logger wraps a handler to inject a request-scoped context object. The composed form must be:

export const POST = withRequestContext(
defineRoute(ResponseSchema, async (request) => { ... })
);

Not:

export const POST = defineRoute(ResponseSchema,
withRequestContext(async (request) => { ... })
);

The outer-wrapping order matters for request-context propagation. The codemod must detect withRequestContext as the outermost call and preserve it.

Corrected S265: this repo is on Next.js 16.2.6 (empirically confirmed, §12 — package.json pins next: 16.2.6). The S11 draft said “Next.js 15”. The async-params shape (params: Promise<{...}>) is unchanged between 15 and 16, so the codemod behaviour below still holds; only the version label was stale. The Response.clone() body-read semantics the pass-through wrapper (§2.4a) depends on are verified against Next 16’s undici runtime in §12.

Approximately 78 of the 92 parameterised routes use the Next.js async params shape:

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
...
}

The codemod must preserve this second argument signature without modification. defineRoute() receives the request and the context argument transparently — the wrapper function passes both through to the inner handler.

8.3 export const maxDuration and other route config

Section titled “8.3 export const maxDuration and other route config”

Next.js route files may export maxDuration, dynamic, or runtime config constants. These are not function declarations and must not be touched by the codemod.

export const maxDuration = 30; // ← do not modify
export const dynamic = 'force-dynamic'; // ← do not modify

The codemod should identify ExportedDeclarations by their name (must be in {GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS}) and skip any VariableStatement or FunctionDeclaration with a different name.

8.4 getAuthenticatedClient vs getAuthorisedClient

Section titled “8.4 getAuthenticatedClient vs getAuthorisedClient”

Both auth patterns appear in the codebase:

  • getAuthenticatedClient() — returns { success: boolean; user; supabase }; does not enforce role. Used by routes that need only to verify a user session.
  • getAuthorisedClient(roles) — additionally checks user_roles table; used by routes that need editor+/admin+ role enforcement.

Both patterns are valid for defineRoute() wrapping. The codemod should not distinguish between them during shape classification (both produce AUTH_PLAIN or equivalent) but should preserve whichever is present in the original handler.

After sf.save(), the file may have its defineRoute import in a non-Prettier order. The implementation session should run bun run format on the modified files or use ts-morph’s organizeImports() helper to sort imports before saving.

CLAUDE.md gotcha: use bun run test (Vitest) not bun test (Bun’s built-in runner) for the codemod test suite.


DependencyAlready in package.json?Notes
ts-morphYes (used by scripts/ast-dataflow-cli.ts)No new dependency needed
zodYesUsed for schema inference lookup
minimist or built-in util.parseArgsutil.parseArgs (Node 18+, Bun built-in)No new dependency needed

The codemod has zero new production dependencies.

lib/api/define-route.ts (pass-through redesign, §2.4a) adds one internal import: the canonical logger from @/lib/logger (lib/logger/index.ts). No new external dependency.


11. Continuous real-corpus acceptance probe (PLAN mandate)

Section titled “11. Continuous real-corpus acceptance probe (PLAN mandate)”

This is the single most important process fix from S262. In S262 the real-corpus acceptance gate (apply the codemod against the live app/api corpus + run the route unit suite) ran LAST — so all four defects (B1 withRequestContext substring false-positive; B2 Source-A 0/195 real binds on disk; B3 missing z/schema imports → 130+ ReferenceError; B4 NextResponse-inline contract mismatch → 1676/2075 test failures) only surfaced at the end. Each is a defect a continuous probe would have caught on the first slice that introduced it.

Mandate (binding on every implementation Subtask {32.25}/{32.26}/{32.27}): the real-corpus probe runs from the FIRST slice, not as a final gate. Concretely:

  1. The probe. Apply the codemod --apply against a temp copy of the working tree (git archive HEAD | tar -x into mkdtempSync, node_modules symlinked — the exact pattern already in __tests__/integration/ops-t1-codemod-acceptance.integration.test.ts), then run the route unit suite (vitest run __tests__/api) against the migrated copy. The working tree is never mutated.
  2. Run it per slice. {32.25} (pass-through defineRoute) runs the probe to prove the contract redesign does not regress the route suite UNDER pass-through (the B4 reinterpretation). {32.26} (strictness) runs it to surface any pre-existing real drift the tightened schemas now reject (S262 OQ-1) under the INV-FP loud-in-test policy. {32.27} (gate re-finalise) runs it as the green-on-all-AC closing assertion.
  3. It is the AC-8 oracle. AC-8 (PRODUCT §8) is exactly this probe’s route-unit-suite pass under the pass-through wrapper.

12. Empirical verification record (OQ-3 / Q-EX2 pre-ratification check)

Section titled “12. Empirical verification record (OQ-3 / Q-EX2 pre-ratification check)”

Per the pre-ratification empirical-verification forcing function, every external-library API and version claim in this amendment was verified against the installed pin, not a prior survey (the S262 cocoindex precedent: a spec citing 0.3.x symbols drifted silently against an installed 1.0.x). Verified 25/05/2026 in the subo-ast worktree.

ClaimPinned (package.json)CheckResult
Next.js version (corrects §8.2 “Next.js 15”)next: 16.2.6jq .dependencies.next package.jsonPRESENT — 16.2.6. §8.2 label corrected.
Response.clone() then .json() body-read is safe (INV-PT §2.4a.1 depends on it)undici via Next 16node -e clone-then-read both clone + originalPRESENT / BEHAVIOUR-OK — clone read {a:1}, original independently read {a:1}, status preserved. No double-consume.
zod .loose() exists + is the zod-4 passthrough formzod: ^4.4.3 (installed 4.4.3)require('zod') + typeof s.loosePRESENT.loose is a function on zod objects.
zod-4 default z.object STRIPS unknown keys (INV-S sweet-spot claim)zod 4.4.3z.object({a}).safeParse({a,extra})BEHAVIOUR-CONFIRMEDsuccess:true, data = {a} only (extra stripped).
zod-4 z.strictObject REJECTS unknown keys (why it is NOT used)zod 4.4.3z.strictObject({a}).safeParse({a,extra})BEHAVIOUR-CONFIRMEDsuccess:false, code unrecognized_keys.
zod-4 .loose() passes unknown keys (the false-confidence trap)zod 4.4.3z.object({a}).loose().safeParse({a,extra})BEHAVIOUR-CONFIRMEDsuccess:true, data retains extra.
Generated block .loose() / z.unknown() counts (INV-S target)n/a (source corpus)awk BEGIN/END block + grep -cCONFIRMED — 84 .loose() + 10 z.unknown() inside schemas.ts:2314-3334.
Genuine [k: string]: T index signatures among R-WP17 source interfaces (the only .loose() exception)n/agrep -rE '\[\s*\w+\s*:\s*string\s*\]\s*:' types/*.tsESSENTIALLY ZERO — only types/jsdom.d.ts (a test-env declaration), no R-WP17 response interface. INV-S exception expected to fire ~never.
ts-morph available (generator + codemod)ts-morph: ^28.0.0jqPRESENT.
withRequestContextBare is a real symbol (B1 root cause)n/alib/logger/index.ts:57 exportPRESENT — confirms B1 was a genuine substring collision; AC-7 mandates exact-callee match.
B1/B2/B3 fix commits present on this branchn/agit log --oneline dfb4879d 34a00d74 3a6a10f8PRESENT — all three on the worker branch this worktree descends from. (Bearing on {32.27}: the gate’s ESCALATION CANARY, which asserts apply aborts on withRequestContextBare, is now stale — apply no longer aborts — so the canary must be retired and the it.fails ACs un-wrapped.)
Stale cross-ref: docs/specs/silent-failure-prevention-spec.md (cited in define-route.ts header)n/als / findABSENT at the cited path. Non-blocking: the fail-loud principle stands; the {32.25} rework should drop or repoint the dead reference.

Verdict: no ABSENT / SIGNATURE_DRIFT on any load-bearing API. Two BEHAVIOUR confirmations (zod-4 default vs strict vs loose; Response.clone) directly underpin INV-S and INV-PT. One stale doc reference and one stale version label found and corrected/flagged. Cleared for ratification.


  • ops-t1-codemod/PRODUCT.md — user-facing behaviour, modes, acceptance criteria (§8 INV-PT/INV-FP/INV-S).
  • ops-t1-codemod/route-shape-inventory.md — empirical shape counts feeding this design.
  • investigations/type-safety-strategy-research-S262.mdOption-4 fork resolution (§7), the .loose() trap + throw-for-errors obstacle (§2.5), mock-proof table (§6), Liam-answered OQs (§8).
  • docs/continuation-prompts/s262-worker-reports/id32-final-report.yaml — per-AC state, B1–B4 saga, commit map.
  • lib/api/define-route.ts — the wrapper redesigned by §2.4a.
  • scripts/codemods/generate-response-schemas.ts — the generator retightened by §3.1a.
  • lib/logger/index.ts — canonical logger (fail-open drift log, INV-FP) + withRequestContext/withRequestContextBare.
  • lib/ast-dataflow/queries/type-drift-detect.ts — verifier implementation.
  • docs/generated/type-drift-baseline.json — 37-interface baseline (Source A input).
  • investigations/S10-programmatic-migration-feasibility.md §2 W1 — original codemod gap analysis.
  • type-safety-pipeline/decision-OPS-T1.md — RATIFIED-S10 sign-off.
  • docs/reference/test-philosophy.md — test philosophy governing the fixture set.