Skip to content

POC: contract-first pipeline for the bridge service (one-route demo slice) - #1816

Draft
MaximusHaximus wants to merge 9 commits into
developfrom
demo/spec-first-pipeline
Draft

POC: contract-first pipeline for the bridge service (one-route demo slice)#1816
MaximusHaximus wants to merge 9 commits into
developfrom
demo/spec-first-pipeline

Conversation

@MaximusHaximus

@MaximusHaximus MaximusHaximus commented Aug 27, 2026

Copy link
Copy Markdown

Proof of concept — not for merge as-is.

/bridge/v1/bridges currently emits global_index as a bare JSON number (≥ 2⁶⁴ for every L1-origin bridge) while the committed swagger declares it a string — JSON.parse in any JS consumer silently rounds it into a different bridge's index. This POC demonstrates the pipeline that makes that class of defect impossible: the contract is authored once (Zod schemas), and both a strict Go server (wrong wire shapes don't compile) and a codec-validating TypeScript client (contract violations are rejected at the first response, exact bigints otherwise) are generated from it.

The demo mounts today's real bridge service and the generated slice side by side on the same canned data, and its tests show the same client rejecting the live endpoint and round-tripping the generated one.

Beyond fixing the defect class, this shape also redraws the client/SDK boundary: the generated client replaces the thousand-plus lines the SDK currently spends hand-maintaining a shadow of this repo's contract, is usable standalone by any consumer who doesn't need the full SDK — and, published from this repo, its release cadence is the server's, so contract changes ship as client semver bumps instead of surprises discovered downstream.

Full walkthrough, rationale, and how to run it: bridgeservice/apispec/README.md. Scope notes (what is deliberately not done) are in the README's final section.

Authors the GET /bridge/v1/bridges contract as Zod schemas and emits an
OpenAPI 3.0 document from them. The two fields that exceed 2^53 --
global_index and amount -- use BigIntegerCodec, whose wire format is a
quoted decimal string, and carry x-go-type: types.BigIntString so the Go
generator substitutes aggkit's existing string-wire wrapper rather than a
raw big.Int (which would marshal as a bare number and reproduce the very
defect this demonstrates).
oapi-codegen v2.8.0 renders bridgeservice/apispec/generated/openapi.yaml
into a strict gin ServerInterface plus models. The x-go-type extension in
the contract makes GlobalIndex and Amount land as types.BigIntString --
aggkit's existing quoted-string wrapper -- rather than as *big.Int, and
the generated query binding reads the same snake_case parameter names the
current handler does.

Adds github.com/oapi-codegen/runtime for the generated query-parameter
binding; go mod tidy pulled in go-jsonmerge transitively and moved
go-colorable and x/time up to the versions it requires.
One gin engine mounts the real BridgeService at its normal routes --
instantiated the way bridgeservice's own test suite does it, with mocked
syncers over canned rows -- and the generated strict server at
/specfirst. Both render the same three bridges, including an L1-origin
row whose global index is 2^64+5 and whose amount is 10^18.

Tests assert on the raw response bytes rather than on decoded values,
because Go decodes both forms losslessly; the divergence exists only in
the serialised output, which is exactly why it survived the current test
suite.
The hey-api client is generated from the same openapi.yaml as the Go
server, with @polygonlabs/zod-to-openapi-heyapi sourcing the actual Zod
schemas rather than reconstructing them from the spec -- so the response
transformer runs BigIntegerCodec, the schema that defined the field.

Pointed at the endpoint the service serves today the client refuses the
response and names global_index; pointed at the generated endpoint it
returns exact bigints. vitest owns the Go server's lifecycle, so the
whole thing runs from a clean checkout with pnpm install, pnpm run
generate, pnpm run demo.

schemasFrom uses the package's own name plus a self-link rather than the
'#schemas' subpath alias the plugin's README suggests for this layout:
the plugin dynamic-imports the specifier from inside its own node_modules
directory, where a consumer's subpath alias is not visible.
Covers the global_index wire-format defect the demo starts from, why
authoring the contract first removes that class of bug on both the server
and the consumer side, how to run both halves, and what the demo
deliberately does not do.
@polygonlabs/openapi-registry ^3.0.0, zod-codecs ^1.2.0, and
zod-to-openapi-heyapi ^2.0.4 -- the published releases of the fixes this
demo helped surface. The money test now asserts the honest error class:
a 2xx body failing response validation is a ResponseValidationError
(2.0.3 misclassified it as TransportError, the workaround this replaces),
and the typed guard exposes cause.issues and the offending post-parse
body without casts -- the test additionally asserts the rejected body
carries the silently-rounded double, which is the corruption being
refused. Client regenerated under 2.0.4; Go round-trip tests and the
end-to-end demo are green.

Claude-Session: https://claude.ai/code/session_0143a61WSX93nWv3eLd4Ys7E
@polygonlabs/zod-to-openapi-heyapi 2.1.0 resolves schemasFrom from the
consumer's perspective (anchored at the codegen output directory), so the
same-package pattern is a plain package.json imports alias again — the
'link:.' self-dependency and the exports entry it required are deleted.
The generated client now imports '#schemas' directly. Regenerated and
re-verified from a clean state: install, generate, typecheck, Go
round-trip tests, and the end-to-end demo all green.

Claude-Session: https://claude.ai/code/session_0143a61WSX93nWv3eLd4Ys7E
The README prosecuted the wire defect and the strict-server half but
undersold the consumer half of the pipeline: the classified error
taxonomy (TransportError / ResponseValidationError with cause+body /
typed per-operation errors, all via type-predicate guards), two-way
codec marshalling (exact bigints in, wire strings out, types agreeing
with runtime by construction), contract drift being undeliverable
rather than silently corrupting, codec-aware TanStack Query factories
behind one flag, and the single canonical import surface.

Claude-Session: https://claude.ai/code/session_0143a61WSX93nWv3eLd4Ys7E
The generated client replaces the SDK's hand-maintained shadow of the
server contract, leaving the SDK only what is genuinely SDK-shaped
(aggregation, orchestration, on-chain reads); the thin client is usable
standalone without the full SDK; and publishing the client from this
repo ties its release cadence to the server's, so contract changes ship
as semver signals.

Claude-Session: https://claude.ai/code/session_0143a61WSX93nWv3eLd4Ys7E
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant