Internal: Fastify rules (routes from openapi.yaml, problem+json errors, contract test); read by work's executors before Fastify code.
Resources
1Install
npx skillscat add idontlikedrinkpivo/web-dev-pipeline-codex-plugin/typescript-fastify Install via the SkillsCat registry.
Codex runtime. Before using this skill, read
../executor-catalog/references/codex-runtime.md. It defines tool mappings, named-agent dispatch and stage-chat orchestration; use it for inherited Claude Code terms below.
Fastify
Which contract and layout apply
The cases (architecture document, Framework-first, existing repo) and the contract's authority are intypescript/references/which-layout-governs.md, shared by the backend and test skills of both stacks — read it
first. Fastify's placement in them:
- An architecture document exists: routes live where the §5 tree puts the HTTP adapter; a handler parses,
calls one use case or query (at Modest a read is a query function in the adapters, no use case), and maps the
result. Named errors map to statuses through the architecture's «Named error → HTTP» table — one module, an
exhaustiveswitchover the errors'kindwithassertNever, never a mapper per route (typescript→
«Every named error is its own type», «One translation of failures to answers»).
A failure of an outside call reaches the handler wrapped with itscause; where the client only sees a generic
answer, the handler writes one warn line with the cause's name — never keys, addresses or secrets. - Framework-first: the request and response schemas are the boundary.
- Existing repo: also its error body, validation status, casing and page shape, even where they differ from
the spec defaults above; do not migrate the contract unasked. A new rule goes into the service or a domain module
with no Fastify, Drizzle or Zod import.
Rules
- Read the owning package first: Fastify version, type provider, schema style, helpers, errors,
lifecycle, test utilities. A second provider or schema style means two validation paths and two
error shapes. Zod projects: references/zod-type-provider.md. - One explicit handler per route, no generic request executor, so schema, statuses, and inferred
types stay visible where the route is declared. The handler holds no business rule. - A route implementing a spec operation carries its
operationIdandtagsverbatim (andsummarywhen present):operationIdbecomes the method name in generated clients. Change the
spec first when the contract changes. - Internal refactors keep the public contract: paths, statuses, field names, error codes and
bodies, headers. Suggested contract changes go into the answer as proposals, not into the diff. - Reuse registered schemas, format validators, auth hooks, and the global error handler; a duplicate
schema$idis rejected at registration and a second handler answers the same failure twice. - Derive TypeScript types from the route schemas (or from the spec by a generator) when that is the
project's pattern, so type and runtime validation cannot drift. - Plugins only for a real encapsulation, lifecycle, or shared-dependency need. Every plugin opens an
encapsulation context; wrap withfastify-pluginonly when its decorators or hooks must
intentionally escape it. Add a close hook only for a resource the plugin owns. - Build the app in one module, call
listenonly in the entry point, so tests build the server withinjectand no port. Wire use cases and adapters in the composition root, not inside plugins. - Auth: reuse the existing auth plugin or permission helper. Missing or invalid credentials are 401,
missing permission 403. An auth check that must precede body validation runs in a hook that runs
before validation, registered on the prefix it protects so sibling route plugins inherit it. Implementing
registration, password login, tokens or a route guard: read references/auth.md —
libraries, the timing-safe check, explicit algorithms, roles read from the database, rate limits. - Reuse canonical identifiers; change the id format only when the task selects it.
Errors
- One global error handler renders the spec's Problem shape as
application/problem+json;
statuses come from the spec. Handlers and hooks throw named errors; they never build error bodies. - Three cases in the handler: validation errors — the framework's schema and a schema parse inside a
handler alike — with the status,code, and per-field list the contract defines (a brownfield repo
without a contract: its own 400 or 422; a client's bad input is never a 500), named errors (status from the mapping table, or the existingAppError
hierarchy in a brownfield repo), everything else (log it; 500 with a genericdetail, never the
internal message, stack, or SQL). - A database conflict is answered the way the contract declares it (
openapi-spec-generator→ Step 6),
the same in both stacks:- a conflict the repository expects (a duplicate key, a dangling reference) it classifies into a named
error (typescript-drizzle-orm), answered with the status the contract gives that error — never a
bare 409; - a serialization failure (
40001) or deadlock (40P01) is first rerun by the server: a use case
whose transaction can lose to a concurrent one is wrapped inbootstrap/by one shared helper that
reruns its whole run a bounded number of times (the repository never loops). When the attempts run
out, the handler answers the contract's 503 withRetry-After, read through the persistence error
helper; - any other driver error — an unexpected violation included — is the third case: logged with its
SQLSTATE, the contract's 500.
- a conflict the repository expects (a duplicate key, a dangling reference) it classifies into a named
- Every extension member an error can emit (
errors,retryable, a context key) is declared in the
spec's Problem schema and in the shared route response schema, or the serializer strips it. Do not
open the schema to let undeclared members through; error context is not spread into the body
unless the schema declares that member. 5xx context goes to logs only.
Collections and cursors
- Page shape, parameter names,
limitdefault and maximum come from the spec, including how the
last page is marked; brownfield: the repo's existing casing and envelope. With neither a spec nor
a precedent:itemsplus a next-cursor field that is null on the last page, in the repo's casing,
no success/data wrapper. Never offset paging when the contract is keyset. - The cursor is opaque (base64url of the last row's sort values plus the sort it was issued for),
carries values at the column's full stored precision, and is validated before it reaches the
query: decode insidetry, check it is an object of the expected shape, each value has the
column's type, and its sort equals the request's sort. Anything else is the spec's 400, never a 500.
function decodeCursor(raw: string, sort: Sort): Cursor {
let v: unknown
try { v = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')) }
catch (e) { throw new InvalidCursor('undecodable', { cause: e }) } // options second (`typescript` → errorClassNames)
if (!isCursorFor(v, sort)) throw new InvalidCursor('shape') // shape, value types, same sort
return v
}The cursor is not signed: it only says where the caller's own page continues, and every query still applies the
caller's access predicate.
Idempotency keys
When the spec (or the task) declares an Idempotency-Key header on a create: requests without it
behave as before; with it, the key is stored under a unique index and a replay returns the first
call's status and resource instead of a conflict. The race is settled by the constraint (insert on
conflict do nothing, or catch the unique violation, then re-read), never by an in-process map.
Versions side by side
Only for an API with external consumers — its spec carries the contract's own SemVer with the major
in the path (openapi-spec-generator → Contract version). An API called only by the project's own
client has no version prefix: a breaking change ships with the client in the same release.
A breaking change (renamed or removed field, changed status) ships as the next major path prefix
(/api/v2) registered next to the old one, with its own schemas and the same use cases or services;
the old major keeps its schemas and tests unchanged and stays until the sunset the spec announces —
its removal is its own change, never part of the one that adds the new major. No header or
query-parameter versioning unless the repo already does it. Both majors use the one global error
handler; Location headers point into the major that created the resource.
Service endpoints, logs and shutdown
The platform the service is handed to probes it, scrapes it, collects its stdout and stops it with SIGTERM. The code
for all four is written once, in bootstrap/ and the entry point, from
references/service-endpoints.md — read it before touching them.
GET /health/livetouches nothing;GET /health/readychecks the database (2 s) and each store the app cannot
serve without, never a partner's API;GET /metricsserves the app's ownprom-clientregistry withhttp_request_duration_secondslabelled by the route template. The three sit outside the contract
(NOT_IN_CONTRACT) and outside auth.- Logs are the built-in pino logger, one JSON object per line with
time,level,msg,logger,request_id
anderr; code logs throughrequest.logor a child ofapp.log, neverconsole. No secret, token or personal
data in a line. - The entry point owns SIGTERM and SIGINT:
app.close()withinSHUTDOWN_TIMEOUT_SEC, a resource closed in anonClosehook, exit 0.
The contract test
tests/contract/openapi.contract.test.ts compares the OpenAPI the app builds (@fastify/swagger in dynamic mode,app.swagger()) with documentation/api/openapi.yaml: every operation the app serves exists there with the same
method, path and operationId, the same error statuses, and the same request and success-response schemas. It is
written once from references/contract-test-template.ts
(repo-scaffold); a unit that adds or changes a route keeps it green — a red contract test is a route that differs
from the contract, never a reason to loosen the comparison. Routes therefore declare the contract's error statuses
in their response schema with the shared Problem schema.
Version-sensitive points — references/version-checks.md. In a work packet
their answers come in CURRENT LIB DOCS; where it says none, probe the installed version in the project instead
of fetching docs. Outside work, read the file and check each point you rely on against the installed version.
The reviewer checks
- A handler parses, calls one use case or query, and maps the result — no business rule, no body of an error
built in a route or hook. - Every status a route can answer is one the contract declares for that operation: a duplicate is a named
error, never a bare 409 from a SQLSTATE; a serialization failure or deadlock is 503 withRetry-Afteronly
after the bounded reruns. - A cursor is decoded inside
tryand checked for shape, value types and sort before the query; anything else
is the contract's 400, never a 500. - An idempotency key or a duplicate is settled by the database constraint, never by an in-process map or a
read-then-insert alone. - Auth runs in a hook before validation, registered on the prefix it protects; missing credentials 401, a
missing permission 403. - A new or changed route carries the contract's
operationIdandtags, and the contract test passes. /health/livetouches nothing;/health/readychecks only stores the app cannot serve without, each bounded,
never a partner's API — a partner outage must not take every pod out of rotation.- No metric label or log field comes from raw input:
routeis the template (request.routeOptions.url),
neverrequest.url; no user id, email, token or body in a label or a log line. A label takes a small fixed set of values (the route template, the method, the status class): an id from the path or a user's value in a label makes a new series per value. - A password is hashed with Argon2id (
@node-rs/argon2orargon2), never a barecryptodigest or a
hand-rolled salt; an unknown email still verifies against a dummy hash, so it costs the time a wrong password
does. Login and registration are rate-limited by address and by account where architecture §1 puts it — in the
app,@fastify/rate-limiton those routes, answering the contract's 429 withRetry-After. - A write takes the contract's fields by name: the body is never spread into an insert or an update, and a
field the client may not set — the owner, a role, a status, a price — is not in the request schema, and the
schema refuses extra fields (additionalProperties: false; with Zod the strict object form,zod-skill→
Boundary rules), so a sent one is refused, not stored.