---
name:        express-to-fastify
description: "Migrates one Express 4.x route at a time to Fastify 4.x via an in-process strangler-fig proxy, with contract tests verifying identical status codes, response shapes, and headers before each proxy cutover."
metadata:
  phase:           4
  source_stack:    "Express 4.x, body-parser, cors, helmet, express-session, custom error handler"
  target_stack:    "Fastify 4.x, @fastify/cors, @fastify/helmet, @fastify/session, Zod or JSON Schema"
  effort_estimate: L
  last_updated:    2026-04-04
---

# 1. Purpose

This skill migrates an Express 4.x API server to Fastify 4.x one route at a time, using a
strangler-fig approach where an in-process proxy (or nginx upstream block) forwards requests
to either the Express instance or the Fastify instance based on path prefix. Both servers run
in the same Node.js process during the transition — Fastify registers `fastify.all('*',
expressApp)` as a catch-all fallback, so unmitigated routes are silently handled by Express
without a deploy change. The migration is gated per route by contract tests: a `supertest`
assertion suite runs against Express to capture the behavioral baseline, and a matching
`inject()` suite runs against Fastify — both must produce identical status codes, response
body shapes, and headers before the proxy is updated for that path. The key risks are not
syntactic but semantic: Express middleware executes in a global ordered stack while Fastify
hooks are scoped per plugin, so a route that relies on a globally-registered Express middleware
(auth, rate-limiting, request-id injection) must have an equivalent hook registered in the
correct Fastify plugin scope. Session handling carries additional risk — `@fastify/session`
requires explicit cookie and store configuration that Express's `express-session` defaults
assume implicitly; a mismatch silently issues new sessions instead of resuming existing ones.

---

# 2. Trigger Conditions

**Use when:**
- A Phase 1 audit has produced a complete route inventory for the Express application — every route with its method, path, ordered middleware chain, and whether it reads or writes `req.session`.
- A Phase 2 migration manifest exists and specifies the order in which routes are migrated (low-risk, stateless, session-free routes first; session-bearing and auth routes last).
- The in-process proxy or nginx upstream is configured and smoke-tested — requests can be shifted per path prefix without a deploy.
- The Fastify instance boots alongside the Express instance in the development environment and responds to `GET /healthz` on its port.
- The pre-migration `supertest` contract test suite is green on the current commit for `target_route`.
- `@fastify/session`, `@fastify/cors`, and `@fastify/helmet` are installed and configured — Phase 3 plugin scaffolding is complete.

**Do NOT use when:**
- The route inventory from Phase 1 is incomplete — migrating routes without a full inventory risks silently dropping Express routes that have no Fastify counterpart.
- Any Express middleware in the global stack has no Fastify equivalent identified — migrate or replicate the middleware in Phase 3 before migrating routes that depend on it.
- `req.session` is used by `target_route` and `@fastify/session` has not been configured with a matching store, secret, and cookie options — silent session invalidation is worse than a 500.
- The Express application uses `app.router` (Express 3.x pattern) or custom monkey-patched `res` methods — this skill targets stock Express 4.x only.
- The Fastify instance is receiving production traffic — run this skill only in dev and staging until all routes have passed contract tests and a load test.
- The Phase 1 audit flagged `target_route` as having untested error paths — write the missing tests before migrating; the contract tests cannot establish a baseline for behavior that is not tested.

---

# 3. Inputs

**Required:**

| Input | Type | Description |
|-------|------|-------------|
| `target_route` | string | The route to migrate in this invocation, in `METHOD /path/pattern` format (e.g., `POST /api/users`). Must match an entry in `route_manifest_path`. |
| `repo_root` | file-path | Absolute path to the repository root. All commands run from here. |
| `route_manifest_path` | file-path | Path to the route manifest JSON produced by Phase 2 (e.g., `output/revamp-spec-routes.json`). Contains middleware chains, body shapes, session usage, and error paths per route. |
| `express_entry` | file-path | Repo-root-relative path to the file that creates and exports the Express `app` instance (e.g., `src/app.ts`). Used to read global middleware registration order. |
| `fastify_entry` | file-path | Repo-root-relative path to the Fastify server factory (e.g., `src/fastify/app.ts`). The new route plugin is registered here. |
| `proxy_config` | file-path | Path to the nginx location config or in-process proxy route table. Updated in Step 10 to shift traffic for `target_route` to Fastify. |
| `fastify_port` | integer | Port the Fastify instance listens on during parallel running (e.g., `3001`). Must differ from the Express port. |

**Optional:**

| Input | Type | Default | Description |
|-------|------|---------|-------------|
| `schema_format` | enum(`zod`, `json-schema`) | `json-schema` | How to write request/response schemas. `zod` requires `zod-to-json-schema`; `json-schema` uses native Fastify schema objects. |
| `dry_run` | boolean | `false` | Analyze and write the proposed route file and contract tests, but do not update the proxy config or register the route in `fastify_entry`. |
| `test_timeout_ms` | integer | `5000` | Timeout per contract test assertion (both supertest and inject). Increase for routes that call slow external services. |
| `k6_vus` | integer | `50` | Number of virtual users for the p99 latency load test in Step 11. |
| `k6_duration` | string | `30s` | Duration of the k6 load test. |

<!--
  STOP CONDITIONS:
  - If `target_route` does not match any entry in `route_manifest_path`, halt:
    "target_route '<value>' is not in the migration manifest. Add it to <route_manifest_path>
     or check for a method/path typo."
  - If `route_manifest_path` does not exist, halt:
    "I need a route manifest at <route_manifest_path>. Run /revamp-spec to generate it,
     or provide the path to an existing manifest."
  - If `@fastify/session` is not in package.json and the manifest entry for target_route
    marks session_usage: true, halt:
    "@fastify/session is not installed. Install and configure it (Phase 3) before migrating
     a session-bearing route."
-->

---

# 4. Steps

1. Read `route_manifest_path`. Locate the entry for `target_route`. Extract and record:
   - `middleware_chain`: ordered list of middleware names applied globally and per-route (e.g., `[requestId, rateLimit, requireAuth, validateBody]`).
   - `body_shape`: JSON schema or TypeScript type of the expected request body (null for GET/DELETE).
   - `response_shape`: JSON schema or TypeScript type of the success response body.
   - `session_usage`: boolean; which session fields are read (`request.session.userId`) or written.
   - `error_paths`: list of conditions that trigger non-200 responses and their expected status codes.
   - `custom_headers_set`: any response headers the handler sets explicitly beyond `Content-Type`.
   - If any field is marked `unknown` or missing in the manifest: STOP. Ask the user to fill in the manifest entry before proceeding — do not infer.

2. Read `express_entry`. Extract the ordered list of globally-registered Express middleware (the sequence of `app.use(...)` calls). Cross-reference with `middleware_chain` from Step 1 to identify which global middleware the route implicitly depends on. For each global middleware, confirm a Fastify equivalent is registered in `fastify_entry` (either as a global `fastify.addHook` or as a plugin-scoped hook). Write any gap as a blocking item in the migration log — do not proceed past Step 2 if a gap exists.

3. Read the Express handler file(s) for `target_route`. Identify every use of:
   - `next(err)` — must become `throw err` in Fastify (or `reply.send(err)` for explicit control).
   - `res.status(N).json(body)` — must become `reply.status(N).send(body)`.
   - `req.body.*` — body is already parsed by Fastify if `Content-Type: application/json`; add a note if the Express handler has a guard for missing `Content-Type`.
   - `req.session.*` — maps to `request.session.*` under `@fastify/session`; flag if the session key names differ.
   - `res.set(header, value)` — maps to `reply.header(header, value)`.
   Write the translation map to the migration log.

4. Write the Fastify route plugin file. Apply the translation map from Step 3.

   **Before (Express) — illustrative example for `POST /api/users`:**
   ```typescript
   // src/routes/users.ts
   import { Router } from 'express';
   import { body, validationResult } from 'express-validator';
   import { db } from '../db';
   import { requireAuth } from '../middleware/auth';

   const router = Router();

   router.post(
     '/api/users',
     requireAuth,                          // route-level middleware
     body('email').isEmail(),
     body('name').notEmpty().trim(),
     async (req, res, next) => {
       const errors = validationResult(req);
       if (!errors.isEmpty()) {
         return res.status(422).json({ errors: errors.array() });
       }
       try {
         const user = await db.user.create({
           data: { email: req.body.email, name: req.body.name },
         });
         res.status(201).json({ id: user.id, email: user.email, name: user.name });
       } catch (err) {
         next(err);                        // reaches the global Express error handler
       }
     }
   );

   export default router;
   ```

   **After (Fastify) — translated equivalent:**
   ```typescript
   // src/fastify/routes/users.ts
   import { FastifyPluginAsync } from 'fastify';
   import { z } from 'zod';
   import { zodToJsonSchema } from 'zod-to-json-schema';   // omit if schema_format === 'json-schema'
   import { db } from '../../db';

   // Schema replaces the express-validator chain.
   // Fastify rejects invalid bodies with 400 before the handler runs — no manual validationResult() check.
   const CreateUserBody = z.object({
     email: z.string().email(),
     name:  z.string().min(1).trim(),
   });

   // Response schema enables fast-json-stringify serialization and strips undeclared fields.
   const UserCreatedReply = z.object({
     id:    z.string(),
     email: z.string(),
     name:  z.string(),
   });

   const usersRoutes: FastifyPluginAsync = async (fastify) => {
     fastify.post<{
       Body:  z.infer<typeof CreateUserBody>;
       Reply: z.infer<typeof UserCreatedReply>;
     }>(
       '/api/users',
       {
         // preHandler runs after global hooks; replaces route-level middleware arrays.
         // fastify.authenticate must be decorated on the instance — registered in fastify_entry.
         preHandler: [fastify.authenticate],

         schema: {
           body:     zodToJsonSchema(CreateUserBody),
           response: { 201: zodToJsonSchema(UserCreatedReply) },
           // Fastify returns 400 (not 422) for schema violations by default.
           // If Express returned 422, override the error handler or set a custom 400 → 422 mapping.
         },
       },
       async (request, reply) => {
         // request.body is validated — access fields directly.
         const { email, name } = request.body;

         // Errors thrown here reach setErrorHandler, not Express's next(err) stack.
         // Ensure fastify_entry registers a setErrorHandler that returns the same
         // shape as the Express global error handler for DB errors.
         const user = await db.user.create({ data: { email, name } });

         return reply.status(201).send({ id: user.id, email: user.email, name: user.name });
       }
     );
   };

   export default usersRoutes;
   ```

   **Translation decisions applied above — adapt these for `target_route`:**
   | Express pattern | Fastify equivalent | Risk note |
   |---|---|---|
   | Route-level middleware array | `preHandler` array in route options | Order is preserved; verify global hooks don't double-apply |
   | `express-validator` chain | Schema object (`body` key) | Fastify returns 400; Express-validator can return 422 — check contract |
   | `next(err)` | `throw err` | Fastify passes thrown errors to `setErrorHandler`; no equivalent to Express's 4-arg error middleware |
   | `res.status(N).json(body)` | `reply.status(N).send(body)` | `send` with an object sets `Content-Type: application/json` automatically |
   | `req.body.*` | `request.body.*` | Fastify requires `Content-Type: application/json`; Express with body-parser is more lenient — add `addContentTypeParser` if clients omit the header |
   | `req.session.*` | `request.session.*` | Field names must match; cookie options (maxAge, secure) must be re-verified in `@fastify/session` config |
   | `res.set(k, v)` | `reply.header(k, v)` | Direct rename; no behavior difference |

   - If this fails to compile (`npx tsc --noEmit`): check that the Fastify generic type parameters match the schema, and that `fastify.authenticate` is declared as a decoration in a `.d.ts` file.

5. Register the new plugin in `fastify_entry`:
   ```typescript
   // src/fastify/app.ts  (add this line alongside other route registrations)
   fastify.register(usersRoutes);   // or: fastify.register(import('./routes/users'))
   ```
   Run `npx tsc --noEmit` from `repo_root`. Confirm 0 errors before proceeding.

6. Write a `supertest` contract test for `target_route` against the Express server. The test must assert every item in the `error_paths` list and the success case. Save to `src/__tests__/contracts/<route-slug>-express.test.ts`.

   Run: `npx jest src/__tests__/contracts/<route-slug>-express.test.ts --testTimeout=<test_timeout_ms>`. Must exit 0.
   - If it fails: STOP. The Express behavior is not fully known. Fix or extend the test until it passes — the Fastify contract test is meaningless without a green Express baseline.

7. Write a matching `inject()` contract test for `target_route` against the Fastify server. Use the same inputs and assert the same status codes, body shapes, and headers as the Express test. Save to `src/__tests__/contracts/<route-slug>-fastify.test.ts`.

   Example structure:
   ```typescript
   // src/__tests__/contracts/post-api-users-fastify.test.ts
   import { buildFastify } from '../../fastify/app';

   describe('POST /api/users (Fastify)', () => {
     const app = buildFastify({ logger: false });
     beforeAll(() => app.ready());
     afterAll(() => app.close());

     it('returns 201 with {id, email, name} for valid input', async () => {
       const res = await app.inject({
         method:  'POST',
         url:     '/api/users',
         payload: { email: 'alice@example.com', name: 'Alice' },
         headers: { 'content-type': 'application/json', authorization: 'Bearer <test-token>' },
       });
       expect(res.statusCode).toBe(201);
       expect(res.json()).toMatchObject({ email: 'alice@example.com', name: 'Alice' });
       expect(res.headers['content-type']).toMatch(/application\/json/);
     });

     it('returns 400 for invalid email', async () => {
       const res = await app.inject({
         method:  'POST',
         url:     '/api/users',
         payload: { email: 'not-an-email', name: 'Alice' },
         headers: { 'content-type': 'application/json', authorization: 'Bearer <test-token>' },
       });
       // NOTE: Fastify schema validation returns 400; Express-validator returned 422.
       // If the contract test requires 422, add a custom Fastify error handler that maps
       // FST_ERR_VALIDATION to 422 — document this decision in the migration log.
       expect(res.statusCode).toBe(400);
     });

     it('returns 401 when authorization header is absent', async () => {
       const res = await app.inject({
         method: 'POST', url: '/api/users',
         payload: { email: 'alice@example.com', name: 'Alice' },
         headers: { 'content-type': 'application/json' },
       });
       expect(res.statusCode).toBe(401);
     });
   });
   ```

   Run: `npx jest src/__tests__/contracts/<route-slug>-fastify.test.ts --testTimeout=<test_timeout_ms>`. Must exit 0.
   - If status code differs from Express: check the translation table in Step 4. If the divergence is intentional (e.g., 400 vs 422 for validation), document it explicitly in the migration log and get sign-off before proceeding. Do not silently accept a status code change.
   - If body shape differs: re-read the response schema. `fast-json-stringify` strips fields not declared in the schema — if a field is missing from the Fastify response, add it to `UserCreatedReply`.
   - If headers differ: check for headers set by global Express middleware (e.g., `X-Request-Id`, `X-RateLimit-*`) that may not be replicated in the Fastify hook chain.

8. → Hand off to `equivalence-validator` (see Section 5. Agent Handoffs) to diff both contract test outputs side by side and produce a formal divergence report. Wait for `output/express-to-fastify-divergence-<route-slug>-<timestamp>.md` before continuing. If the divergence report contains any item marked `BREAKING`, resolve it before Step 9.

9. If `dry_run` is `true`: write the migration log with the route file, contract tests, and divergence report, then STOP. Do not update the proxy config.

10. Update `proxy_config` to route requests matching `target_route`'s path to the Fastify port.

    **nginx example (add inside the `server` block, before the Express fallback location):**
    ```nginx
    # Migrated by skills/04-migrate/backend/express-to-fastify — <timestamp>
    # Route: POST /api/users
    location = /api/users {
      limit_except POST { deny all; }
      proxy_pass         http://127.0.0.1:<fastify_port>;
      proxy_http_version 1.1;
      proxy_set_header   Connection       "";
      proxy_set_header   Host             $host;
      proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;
      proxy_set_header   X-Migrated-By    fastify;   # marker for log filtering
    }
    ```

    **In-process proxy example (update the route table before the Express catch-all):**
    ```typescript
    // src/proxy/routeTable.ts
    // Prepend; order matters — first match wins.
    { method: 'POST', path: '/api/users', target: 'fastify' },
    ```

    Reload the proxy config (`nginx -s reload` or restart the Node process). Send a test request and confirm the response contains `X-Migrated-By: fastify` (or equivalent marker). If the marker is absent, the proxy is still routing to Express — check the location block order.

11. Deploy to staging. Monitor the error log for 5 minutes:
    ```bash
    # Tail the access log and filter for 5xx on the migrated path
    tail -f <access_log> | grep '<target_path>' | grep -E ' 5[0-9]{2} '
    ```
    - If any 5xx is observed on `target_route`: this is the hard rollback trigger. Remove the nginx location block (or revert the route table entry), reload the proxy, confirm Express handles the route again, write the incident to the migration log, STOP: "Rolled back `<target_route>` — 5xx observed in staging. See migration log for request details."
    - If no 5xx after 5 minutes: run the p99 latency test (Section 6 `p99-latency` test) and record the result.

12. Write all outputs declared in Section 7. Run every Equivalence Test in Section 6 and record results in `output/express-to-fastify-equiv-<timestamp>.md`. Evaluate every item in Section 9 Done Criteria; report pass/fail inline, then print the final verdict.

---

# 5. Agent Handoffs

## equivalence-validator

- **File:** `agents/equivalence-validator.md`
- **Triggered by:** Step 8
- **Prompt template:**
  ```
  ORIGINAL:    Express handler for <target_route> (src/routes/<route-file>.ts)
  MIGRATED:    Fastify handler for <target_route> (src/fastify/routes/<route-file>.ts)
  REPO_ROOT:   <repo_root>
  TEST_SUITE:  src/__tests__/contracts/<route-slug>-*.test.ts
  OUTPUT_FILE: output/express-to-fastify-divergence-<route-slug>-<timestamp>.md
  ENV:         staging
  TASK:        Diff the contract test outputs for both suites. For each test case, report
               whether status codes, response body fields, and response headers match
               exactly. Mark any divergence as BREAKING (different status code or missing
               required field) or NON-BREAKING (additional field in Fastify response,
               cosmetic header difference). Include a VERDICT: PASS | FAIL | INDETERMINATE.
  ```

## code-archaeologist

- **File:** `agents/code-archaeologist.md`
- **Triggered by:** Step 2 (invoked inline if the middleware gap analysis in Step 2 cannot be completed by reading `express_entry` alone — e.g., middleware is registered across multiple files via `app.use(require('./middleware/...'))`)
- **Prompt template:**
  ```
  TASK:        Trace the complete ordered middleware chain for <target_route> in the
               Express application. Start from <express_entry> and follow every
               app.use(), router.use(), and route-level middleware reference.
               For each middleware, report: name, file path, line number, and whether
               it reads or writes req.session, req.user, req.body, or sets response headers.
  REPO_ROOT:   <repo_root>
  SCOPE:       <repo_root>/src
  OUTPUT_FILE: output/express-to-fastify-middleware-chain-<route-slug>-<timestamp>.md
  FORMAT:      markdown
  ```

---

# 6. Equivalence Tests

<!--
  All tests are run in Step 12. Results written to output/express-to-fastify-equiv-<timestamp>.md.
  <route-slug> is the kebab-case version of target_route (e.g., "post-api-users").
-->

| Test Name | Input | Expected Output | Tool |
|-----------|-------|-----------------|------|
| `express-contract` | `npx jest src/__tests__/contracts/<route-slug>-express.test.ts` (Step 6 result) | Exit code 0. All test cases pass. This is the behavioral baseline — if it fails, there is no valid target to match. | Bash — captured in Step 6. |
| `fastify-contract` | `npx jest src/__tests__/contracts/<route-slug>-fastify.test.ts` (Step 7 result) | Exit code 0. All test cases pass with status codes, body shapes, and headers identical to the Express contract results. | Bash — captured in Step 7. |
| `contract-divergence-clean` | Divergence report at `output/express-to-fastify-divergence-<route-slug>-<timestamp>.md` (Step 8 result) | Report verdict is `PASS`. Zero items marked `BREAKING`. Non-breaking divergences are logged but do not block. | Read — check for `VERDICT: PASS` and absence of `BREAKING`. |
| `no-5xx-in-staging` | `tail -f <access_log> \| grep '<target_path>' \| grep -E ' 5[0-9]{2} '` monitored for 5 minutes after Step 11 proxy cutover | Zero matching lines — grep finds no 5xx responses for the migrated path. | Bash — output captured in migration log during Step 11. |
| `session-parity` | Only if `session_usage: true` in manifest: send authenticated request with a pre-seeded session cookie to both Express (`supertest`) and Fastify (`inject`) | Session data read and written identically; `Set-Cookie` response has same `httpOnly`, `secure`, `sameSite`, and `maxAge` attributes on both. | Bash: `supertest` + `inject` with `--cookie` flag; diff `Set-Cookie` headers. |
| `error-handler-parity` | Send a request to `target_route` that triggers the error handler (e.g., payload causing a DB constraint violation via a test fixture) | Status code and `{error, message}` body shape are identical between Express and Fastify responses. Fastify `setErrorHandler` must mirror Express global error handler output. | Bash: `supertest` + `inject` with error-triggering payload. |
| `p99-latency` | `k6 run --vus <k6_vus> --duration <k6_duration> output/express-to-fastify-k6-<route-slug>.js` against Fastify in staging | p99 latency ≤ Express p99 baseline recorded in the migration log. A regression of ≤5ms is acceptable; >5ms warrants investigation before proceeding to the next route. | Bash: k6 output parsed for `http_req_duration{p(99)}` value. |

---

# 7. Outputs

| Artifact | Path Pattern | Format | Description |
|----------|-------------|--------|-------------|
| Middleware gap analysis | `output/express-to-fastify-middleware-chain-<route-slug>-<timestamp>.md` | markdown | Produced by `code-archaeologist` if invoked in Step 2. Complete ordered middleware chain for `target_route`; consumed in Steps 3–4 to verify every middleware has a Fastify hook equivalent. |
| Fastify route file | `src/fastify/routes/<route-slug>.ts` | TypeScript | The migrated Fastify route plugin. Committed to the repo as a permanent artifact. |
| Express contract test | `src/__tests__/contracts/<route-slug>-express.test.ts` | TypeScript | Supertest behavioral baseline for `target_route`. Committed; runs in CI against Express until Express is fully removed. |
| Fastify contract test | `src/__tests__/contracts/<route-slug>-fastify.test.ts` | TypeScript | `inject()` assertions mirroring the Express contract. Committed; becomes the permanent test for this route after Express is removed. |
| Divergence report | `output/express-to-fastify-divergence-<route-slug>-<timestamp>.md` | markdown | Produced by `equivalence-validator` in Step 8. Side-by-side diff of contract test outputs with BREAKING/NON-BREAKING classification and a VERDICT. |
| k6 load test script | `output/express-to-fastify-k6-<route-slug>.js` | javascript | k6 script targeting `target_route` on Fastify in staging. Generated in Step 12 for the `p99-latency` equivalence test. |
| Migration log | `output/express-to-fastify-log-<timestamp>.md` | markdown | Translation decisions, middleware gap items, status code divergences, p99 baseline, rollback events (if any), confidence level, and numbered assumptions list. Consumed by `/validate` and the PR reviewer. |
| Equivalence test results | `output/express-to-fastify-equiv-<timestamp>.md` | markdown | Pass/fail verdict for every row in Section 6, with raw command output attached. Required by Section 9 Done Criteria. |

---

# 8. References

- `references/strangler-fig-pattern.md` — the in-process proxy with Express catch-all is the "Facade / Proxy" mechanism described here; the `fastify.all('*', expressApp)` pattern is the traffic-shifting seam.
- `references/migration-anti-patterns.md` — "Migrating Shared Infrastructure Last" (§6) applies to the auth and session middleware: migrate those in Phase 3, not here. "Confidence Without Evidence" (§7) is why every translation decision in Step 3 must cite a line number.
- `references/stack-compatibility-matrix.md` — "Framework Compatibility: Express 4 → Fastify 4" row; check before assuming middleware API parity.
- `skills/03-prepare/` — plugin scaffolding (`@fastify/session`, `@fastify/cors`, `@fastify/helmet`), the in-process proxy setup, and the Fastify instance bootstrap must be complete before this skill runs.
- `skills/05-validate/` — run `/validate` after all routes are migrated and Express is removed; the full contract test suite and a production load test are the final gates.
- `agents/equivalence-validator.md` — primary agent in Step 8; review its VERDICT semantics before interpreting the divergence report.
- `agents/code-archaeologist.md` — optional agent in Step 2; invoke when middleware is registered across multiple files.
- `https://fastify.dev/docs/latest/Guides/Migration-Guide-V4/` — Fastify v4 migration guide; consult for plugin API changes if upgrading from Fastify v3.
- `https://fastify.dev/docs/latest/Reference/Hooks/` — hook lifecycle order; essential for correctly scoping `preHandler`, `onRequest`, and `onSend` equivalents of Express middleware.

---

# 9. Done Criteria

<!--
  Claude evaluates each item and reports pass/fail before declaring this skill complete.
  Any unchecked item means the skill is NOT complete for this route invocation.
  The first four gates (1–4) apply to the final state of the full migration (all routes done).
  Gates 5–10 are per-invocation and must pass for every individual route before the next route begins.
  Mandatory universal gates are 11–15.
-->

- [ ] **[Final state]** `express` is absent from `package.json` `dependencies` — `grep '"express"' <repo_root>/package.json` returns no matches. This gate is only checked after all routes in the manifest have been migrated. Skip and note if this is not the final route.
- [ ] **[Final state]** All routes in `route_manifest_path` have a corresponding entry in `src/fastify/routes/` and are registered in `fastify_entry` — glob `src/fastify/routes/*.ts` and cross-reference with the manifest; no manifest entry is unmapped.
- [ ] **[Final state]** The full contract test suite (`npx jest src/__tests__/contracts/`) exits 0 — all Express and Fastify contract tests pass simultaneously in CI.
- [ ] **[Final state]** p99 latency for all migrated routes is equal to or better than the Express baseline — all `p99-latency` results in the migration log show no regression >5ms.
- [ ] `express-contract` equivalence test recorded as **pass** — the behavioral baseline for `target_route` is established and green.
- [ ] `fastify-contract` equivalence test recorded as **pass** — Fastify handler produces identical status codes, body shapes, and headers.
- [ ] `contract-divergence-clean` equivalence test recorded as **pass** — divergence report verdict is `PASS`; zero `BREAKING` items.
- [ ] `no-5xx-in-staging` equivalence test recorded as **pass** — no 5xx observed for `target_route` during the 5-minute post-cutover monitoring window.
- [ ] `error-handler-parity` equivalence test recorded as **pass** — error response shape is identical between Express and Fastify for the error-triggering payload.
- [ ] If `session_usage: true` in manifest: `session-parity` equivalence test recorded as **pass**. If `session_usage: false`, mark this gate N/A and note.
- [ ] All output files listed in Section 7 exist at their declared paths — verify each with a file read. (The middleware gap analysis is only required if `code-archaeologist` was invoked in Step 2.)
- [ ] Every equivalence test in Section 6 has a recorded result in `output/express-to-fastify-equiv-<timestamp>.md` — no test name is missing from the results file. (Session-parity is marked N/A if not applicable, not absent.)
- [ ] No equivalence test in Section 6 is recorded as **fail** — grep the results file for `fail`; zero matches required. N/A entries do not count as fail.
- [ ] The migration log includes a confidence level (High / Medium / Low) — grep `output/express-to-fastify-log-<timestamp>.md` for `Confidence:`.
- [ ] The migration log includes a numbered assumptions list — grep `output/express-to-fastify-log-<timestamp>.md` for `Assumptions:`.
