api-contract
The executable definition of the HTTP API, kept outside every implementation of it.
statshub-api-contract is how the Go port is proven faithful rather than
merely finished. It records golden responses from legacy
and replays them against api.
The rule it exists to enforce
A test that imports the implementation dies with the implementation.
A test calling TypeScript functions in legacy is deleted along with the code
it tested, the moment that route becomes Go. A test constructing a chi.Mux
cannot check that Go matches what legacy actually served. Only a test that
speaks HTTP to both survives the port and can compare them.
Running it
bun run contract:record # capture goldens from legacy
bun run contract:verify # replay them against Go
bun run contract:compare # where cases and the ledger disagreeAgainst the parity stack, which brings both implementations up on one database:
bun run parity:up
bun run parity:record
bun run parity:verifyThe parity stack covers what that compose file actually starts.
compare reads the port ledger
contract compare parses apps/statshub-api/docs/go-port.md for the routes claimed as
ported. The path is pinned in src/cli.ts, so moving that file breaks the
command — and the parser matches the Ported heading, so renaming it breaks it
quietly rather than loudly.
Shape rules
src/shape.ts encodes the serialisation decisions clients depend on: numeric
columns serialising as JSON strings, nullable columns staying present rather
than being omitted. src/shape.test.ts checks them.
Read those before changing how anything serialises. A shape change that passes Go's own tests can still break every client — which is the whole reason this package is not inside either implementation.
When the port is meant to differ
Some differences are decisions, not regressions. /api/incident/{id} is the
live example: a non-uuid id reaches a uuid column, Postgres raises 22P02, and
both implementations answer 500 — but legacy serialised the driver's error
object into the response, so the client got back the SQL text, the bound
parameters and the server's source file and line. httpx.Fail sends error as
{} and logs the rest.
expect.divergence records that, keyed by path, with the reason as the value:
"divergence": {
"error": "Legacy serialised the postgres-js error object straight into the response, leaking the SQL text and the bound parameters. httpx.Fail sends error as {} and logs the real error."
}Shape and body findings at or under a declared path stop failing the case. Nothing else can be declared — a status, header or transport difference is how a port breaks a client, and no reason makes one expected.
A declared path that produces no finding fails the case. That is what keeps this from being a mute button: if the two sides come to agree, the marker is hiding a regression that has not happened yet, and the suite says so rather than accepting the difference silently when someone reintroduces it.
Related gates
| Gate | What it proves |
|---|---|
contract verify | Go's bytes match what legacy served |
internal/api/v2_conformance_test.go | The OpenAPI document matches what the handlers write |
bun run openapi:check in apps/statshub-api | The checked-in document is not stale |

