Cross-Transport Parity Invariant
Connectum exposes two transports for ConnectRPC services:
- the HTTP/2 transport — the production wire protocol;
- the in-process transport —
createLocalTransport(server)/Server.client(ServiceDesc), which delivers requests into the registered route handlers in memory, without a socket, over Connect's binary protocol (messages are still encoded and decoded).
The framework guarantees a Behavioural Parity invariant between them:
For every observable behaviour of a ConnectRPC service, the in-process transport produces results that structurally match the HTTP/2 transport. Observable behaviours include response payloads, response headers and trailers,
ConnectErrorcodes / messages / metadata /details, the order of streaming messages, cancellation propagation, OpenTelemetry spans (modulo theconnectum.transportattribute), and metric label sets (modulo thetransportlabel).
This invariant is release-blocking: any PR that breaks it must either be revised or accompanied by a documented carve-out in the spec.
How the invariant is enforced
transportParityTest()driver (@connectum/testing/parity) runs every scenario twice — once overcreateGrpcTransport({ baseUrl })and once overcreateLocalTransport(server)— and performs a structural diff on the normalized result (response, headers, error, OTEL spans, metrics).What the default comparison (
defaultCompare) treats as equal, beyond identical values:- Protocol headers. Response headers, trailers and
ConnectErrormetadata are compared as application data. The HTTP/2 leg and the in-process leg carry different protocol headers for identical behaviour (content-type,content-length,date,grpc-encoding,grpc-status,grpc-message), socontent-type,content-length,content-encoding,accept-encoding,date,teand thegrpc-*andconnect-*name families are dropped on both sides first. Every other entry is compared literally, so a scenario can report the headers exactly as the client sees them. A scenario that must compare a protocol header passes its owncompare. - Duration histograms. A histogram measured in a unit of time (
rpc.server.call.duration,rpc.client.call.duration) holds the elapsed time of that run, which differs between runs. It is compared by its observation count and attributes, not by sum, minimum, maximum or buckets. Names, descriptions, units, types and attributes of every metric, and the values of every other metric (sizes, counters, gauges), are compared literally. - Spans are compared without trace and span identifiers (only whether a span has a parent), and
readMaxBytesrejections are equal when they name the same limit, whether or not the text includes the observed size.
Telemetry from
@connectum/otelreachesctx.spansandctx.metricsonly when the scenario sets it up: run withOTEL_TRACES_EXPORTER=noneandOTEL_METRICS_EXPORTER=none, and callshutdownProvider()from@connectum/otelat the start of every scenario, before registering the collectors withtrace.setGlobalTracerProvider()andmetrics.setGlobalMeterProvider().@connectum/otelkeeps the tracer of the first provider registered, so without the shutdown every later scenario records nothing, and two empty lists compare as equal; assert that the telemetry you compare is not empty. A secondctx.metrics.flush()returns only what was recorded since the previous one, andflush()after the run was disposed throws.The opt-in
telemetry: { shutdownProvider }option makes the driver do that sequence around every run: it resets the@connectum/otelprovider and registers that run's span and metric collectors, so the second and later scenarios in a process record spans too (see Spans and metrics of@connectum/otelin a test).- Protocol headers. Response headers, trailers and
Parity suite — aggregated by
scripts/parity-suite.sh, which runs the parity tests of@connectum/testing(interceptors, validation, streaming, error mapping, coexistence, request admission, reflection, catalog-routedctx.call/ctx.stream),@connectum/auth(authentication and authorization), and@connectum/otel(OTEL). Validation and authorization scenarios run the shipped interceptors (createDefaultInterceptors(),createJwtAuthInterceptor,createProtoAuthzInterceptor) against fixtures with realbuf.validateandconnectum.auth.v1annotations; each scenario checks the outcome against an expected value as well as comparing the two transports. A test-local interceptor that imitates them does not count as coverage.HTTP ↔ in-process parityCI check (.github/workflows/parity-gate.yml) runs the suite on every pull request and every push tomain.
When you MUST add a parity scenario
Any change that touches observable RPC behaviour must add (or extend) a transportParityTest() scenario in the appropriate file under packages/testing/tests/parity/, packages/auth/tests/parity/ (authentication and authorization), or packages/otel/tests/parity/ (observability). This includes, but is not limited to:
- a new interceptor in
@connectum/interceptors; - a new authentication or authorization rule in
@connectum/auth(programmatic or proto-declared); - a new validation rule wired through
protovalidate/buf.validate; - a new OpenTelemetry instrument, attribute, or span event in
@connectum/otel; - any change to
ConnectErrormapping in@connectum/coreor interceptors; - any change to header / trailer propagation;
- any change to streaming semantics (ordering, cancellation, back-pressure surfaces).
If the change is purely transport-local — for example, an HTTP-only header that has no in-process analogue — document the carve-out in the parity test file as a code comment and in the spec.
When parity does not apply
A small set of behaviours are explicitly transport-specific and are not asserted by the parity driver:
- Network-level concerns: TLS, HTTP/2 framing, TCP keepalive,
:authoritypseudo-header, realreq.urlhost/port. - Wire-format concerns: gzip/identity content-encoding negotiation, message framing on the wire, and the protocol headers that carry them (the driver drops them before comparing headers, trailers and error metadata).
- The measured time of a duration histogram (its observation count is compared).
- The synthetic origin
https://in-memory/<service>/<method>that the in-process transport injects for interceptors that readreq.url. - The
connectum.transportspan attribute and thetransportmetric label, which differ by design and are stripped before structural diff. - The diagnostic text of a
readMaxBytesrejection. Connect includes the observed message size only when it knows the total length up front: the gRPC envelope over HTTP does, the in-process unary body does not. So one transport may reportmessage size 70 is larger than configured readMaxBytes 64and the othermessage size is larger than configured readMaxBytes 64.defaultComparetreats exactly this pair as equal when both errors areresource_exhaustedand name the same limit; the code, the limit, metadata, details, and whether the handler ran are still compared as-is, and any other message difference still fails.
Server shutdown is not an exception either: server.stop() aborts the handler signal of in-flight calls on both transports (since 1.3.0).
Request admission itself is not an exception: a server's requestGate and readMaxBytes apply identically on both transports, with no in-process exemption (see Request admission).
Pull request checklist
Every PR that touches a service-observable surface must:
The parity check fails on a structural diff. Run it locally with bash ./scripts/parity-suite.sh from the framework root before submitting a change.
References
- In-process transport guide
- Parity coverage report
- Internal design notes: see ADR for in-process transport (registry-based local invoke)