PQ CRYPTA PLATFORM

🏠 Main

🧪 Interactive Apps

📰 News

🛡️ PQ Crypta Proxy

👤 Account

⟨ QUANTUM ERROR PORTAL ⟩

Navigate the Error Dimensions

We do not ask your client whether it handled the protocol correctly. We watch what it actually did on the wire.

This is a live service, not a description of one. Point an HTTP/3 client at any port below and the server will emit something awkward but legal — a reserved frame type, a duplicated SETTINGS identifier, a control stream that opens with the wrong frame, an unknown QUIC frame type, a Stateless Reset, a path-MTU black hole — then record what your client did about it.

59adversarial tests
28HTTP/3 layer
23QUIC layer
8TLS layer
4460–4518UDP ports

QUIC

23 tests

correctness
8
discretionary
7
resilience
5
interoperability
2
extensibility
1

HTTP/3

28 tests

correctness
14
interoperability
5
discretionary
4
extensibility
3
resilience
2

TLS

8 tests

correctness
3
discretionary
2
extensibility
1
interoperability
1
resilience
1

What a result means

PassThe client demonstrated the behaviour the specification requires.
FailThe client did something the specification prohibits.
InconclusiveThe run did not establish what your client does here — either it was never put in the situation, or its answer admits more than one reading. Not a failure, and never counted as one.

Running it

Each test has its own UDP port. Results are filed under a session started from your address, so the test connections need nothing special — just run them from the machine that asked for the session.

A port per test rather than a path per test, because several anomalies are properties of the endpoint rather than of a connection: the set of QUIC versions offered is decided before any connection exists, so a test of it has to own the whole port.

# start a session
SESSION=$(curl -sX POST https://conformance.pqcrypta.com/session | jq -r .id)

# walk the catalogue
curl -s https://conformance.pqcrypta.com/catalog.json | jq -r '.tests[].port' | while read PORT; do
    curl -s --http3-only --max-time 15 "https://conformance.pqcrypta.com:$PORT/" -o /dev/null
  done

# read the result
curl -s https://conformance.pqcrypta.com/report/$SESSION.json | jq .totals

Without jq: grep -o '"port":[0-9]*' | cut -d: -f2 pulls the ports out of the catalogue. The report is also a page at /report/<session> and an SVG badge at /badge/<session>.svg.

In CI

h3-conformance does the above and exits non-zero if anything failed. It is a harness rather than a client: it runs a command you supply, once per test, so whatever your library connects with is what gets measured.

# drive curl
h3-conformance

# drive your own client
h3-conformance --client 'my-client --url {url}'

# one area, machine-readable
h3-conformance --filter h-qpack --json

Exit 0 clean, 1 a test failed, 2 the suite was unreachable — distinct, so an outage never reads as a regression in your client. Inconclusive results never fail a run.

OptionDefaultWhat it does
--hostconformance.pqcrypta.comWhich suite to run against.
--clientcurlCommand run once per test. {url} and {port} are substituted; its exit status is ignored, because a client failing a test frequently should exit non-zero and that is a result, not an error.
--filter—Run only tests whose id contains this, e.g. h-qpack.
--timeout30sSeconds to allow each client invocation before giving up on it.
--jsonoffPrint the report as JSON instead of text.
--quietoffOnly print failures and the summary.

The report

A session collects results as you walk the ports, and is readable three ways: as a page at /report/<session>, as JSON at /report/<session>.json, and as an SVG badge at /badge/<session>.svg. All three are public and CORS-open, so CI can fetch them without a key.

{
  "session": "8fbecf53c82f7c6481ecca51727c6742",
  "catalog_size": 59,
  "totals":   { "pass": 22, "fail": 0, "inconclusive": 5, "not_run": 0 },
  "by_class": { "correctness": { "pass": 6, "fail": 0, "inconclusive": 3 }, ... },
  "results": [
    { "id": "h-grease-frame", "class": "extensibility", "tier": "http3",
      "spec": "RFC 9114 §7.2.8", "verdict": "pass",
      "detail": "Ignored the unrecognised element and completed the follow-up request." }
  ]
}

Test ids are stable and appear in the URL, the JSON and the badge, so they are safe to pin a CI assertion to. Embed a badge with:

![HTTP/3 conformance](https://conformance.pqcrypta.com/badge/$SESSION.svg)

A badge is a picture of one run, not a live status: re-run the suite and use the new session to refresh it.

How results find their session

By default results are filed under the session started from your address, which is why the walk above needs nothing special. If your tests run from several machines, or from behind a shared address, connect with the server name <session>.conformance.pqcrypta.com instead — the session travels in the SNI, and results follow it rather than the address.

A session expires an hour after it was created, not an hour after its last result: activity does not extend it. Start the session at the beginning of the walk rather than long before, and fetch the report while the run is fresh; nothing is archived.

What a verdict means

The server is the judge. A library that crashes on an unknown frame is in no position to report on itself, so every verdict comes from what the server observed. Every test ends with a liveness probe: after the anomaly, the server expects one ordinary request on the same connection. For the extensibility tests that is the whole game — "ignored it" and "died on it" look identical on the wire until the probe arrives or does not.

The required answer is not the same everywhere, and sometimes it is the opposite. Ignoring a reserved HTTP/3 frame is a pass — reserved frame types exist to be ignored. Ignoring a reserved QUIC frame is a failure, because QUIC reserves no ignorable frame types and an unknown frame carries no length to skip it by. Two tests that look alike on the wire require opposite behaviour, which is why the classes exist.

An anomaly written to the control stream is a case of its own. That stream is unidirectional and nothing obliges a client to read it on any schedule, so a one-shot request can finish and close before it is picked up — which is indistinguishable, from this end, from accepting the violation. The response is held back briefly to give a conformant reader its chance, and where the two still cannot be told apart the result is inconclusive rather than a failure.

Some tests cannot be reached by every client, and say so rather than guessing: 0-RTT rejection needs a session ticket from an earlier connection to the same port; ECN reporting is required only where the ECN field is accessible, which a network can strip in transit; QPACK dynamic-table references need the client to have granted the encoder some capacity.

The catalogue

Every test, the port it runs on, and the clause it exercises. The same data is available as JSON.

HTTP/3 layer

TestPortClassSpecification
h-grease-settingsReserved SETTINGS identifier (0x1f·N+0x21)Ignore the unknown setting and complete the request.4473extensibilityRFC 9114 §7.2.4.1
h-grease-frameReserved frame type on the response streamSkip the frame using its length and read the response that follows.4474extensibilityRFC 9114 §7.2.8
h-reserved-uni-streamUnidirectional stream with a reserved stream typeAbort reading the stream or discard it — §6.2.3 permits either. What it forbids is treating it as meaningful, or as fatal to the connection.4475extensibilityRFC 9114 §6.2.3
h-duplicate-settingSETTINGS containing the same identifier twiceEither reject with H3_SETTINGS_ERROR or ignore the repeat — the specification says a receiver MAY treat this as an error, so both are conformant.4476discretionaryRFC 9114 §7.2.4
h-control-frame-unexpectedA DATA frame on the control streamClose the connection with H3_FRAME_UNEXPECTED.4477correctnessRFC 9114 §7.2.1
h-missing-settingsControl stream whose first frame is not SETTINGSClose the connection with H3_MISSING_SETTINGS.4478correctnessRFC 9114 §6.2.1
h-second-control-streamA second control stream opened by the serverClose the connection with H3_STREAM_CREATION_ERROR.4479correctnessRFC 9114 §6.2.1
h-qpack-dynamic-tableField lines referencing dynamic table insertionsApply the encoder-stream insertions and decode the headers correctly.4480interoperabilityRFC 9204 §4.3.3, §4.5.2
h-qpack-huffmanHuffman-coded field lines with maximal paddingDecode without error. Padding of up to 7 bits is legal, not corruption.4481interoperabilityRFC 9204 §4.1.2, RFC 7541 §5.2
h-oversized-field-sectionField section larger than the client's advertised maximumHandle it as an error against that one request, not the whole connection.4482interoperabilityRFC 9114 §4.2.2
h-trailersTrailing field section after the bodyDeliver the trailers to the application after the body completes.4483interoperabilityRFC 9114 §4.1
h-early-hints103 Early Hints before the final responseTreat 103 as informational and keep reading for the final response. RFC 9110 §15.2 makes this a MUST: a client "MUST be able to parse one or more 1xx responses received prior to a final response, even if the client does not expect one". Ignoring the hints is fine — a user agent MAY do that — but closing the connection is not.4484resilienceRFC 9110 §15.2, RFC 8297
h-goawayGOAWAY sent mid-connectionStop opening requests, finish those in flight, and retry idempotent ones elsewhere. The GOAWAY is written once the client's request is running and names the stream after it, so §5.2 puts that request inside the range the server promises to process: finishing it is the behaviour under test. Sent before the request instead, the correct answer would be to close and reconnect, and the test would be measuring a race rather than recovery.4485resilienceRFC 9114 §5.2
h-max-push-idMAX_PUSH_ID sent by the serverReject with H3_FRAME_UNEXPECTED. MAX_PUSH_ID travels client to server only, so a server sending one is using a frame in a direction the specification does not allow.4486correctnessRFC 9114 §7.2.7
h-settings-on-request-streamSETTINGS frame on a request streamClose the connection with H3_FRAME_UNEXPECTED. SETTINGS belongs to the control stream alone: §7.2.4 says that if an endpoint receives one on a different stream it "MUST respond with a connection error of type H3_FRAME_UNEXPECTED".4490correctnessRFC 9114 §7.2.4
h-data-before-headersDATA frame before any HEADERS on the response streamClose the connection with H3_FRAME_UNEXPECTED. A response begins with a field section, and §4.1 makes "receipt of an invalid sequence of frames" a connection error of that type — a body arriving before the headers that describe it is exactly that.4491correctnessRFC 9114 §4.1
h-cancel-push-unsolicitedCANCEL_PUSH for a push ID that was never promisedClose the connection with H3_ID_ERROR. No MAX_PUSH_ID was granted, so every push ID is greater than currently allowed, and §7.2.3 requires a CANCEL_PUSH referencing one to be treated as a connection error of that type.4492correctnessRFC 9114 §7.2.3
h-qpack-blocked-streamField section that blocks until the encoder stream catches upHold the field section, apply the insertions when they arrive on the encoder stream, and complete the request. §2.2.1 makes a section whose Required Insert Count exceeds the decoder's Insert Count a blocked stream — something to be waited on, not an error. Only run when the client advertised both a table capacity and at least one blocked stream; §2.1.2 forbids the encoder from blocking more streams than the decoder promised to support, so a client that permits none cannot be tested on this.4493interoperabilityRFC 9204 §2.1.2, §2.2.1
h-response-stream-resetResponse stream reset mid-body with H3_REQUEST_CANCELLEDAbandon the partial response — §4.1 says a response cancelled after a partial delivery "SHOULD NOT be used". Whether the connection survives is the client's to choose: §8 lets an endpoint "treat a stream error as a connection error under certain circumstances", so keeping the connection and closing it are both conformant. Stalling is the one wrong answer.4494discretionaryRFC 9114 §8, §4.1
h-priority-updatePRIORITY_UPDATE sent by the serverReject it with H3_FRAME_UNEXPECTED, or ignore it — which is conformant depends on whether you implement extensible priorities at all, and that is not observable from here. A client that does implement RFC 9218 is bound by §7.2: servers "MUST NOT send PRIORITY_UPDATE frames of either type", and a client receiving one MUST treat it as a connection error of that type. A client that does not implement it sees frame type 0xf0700 as simply unknown, and RFC 9114 §9 requires unknown frame types to be ignored — so ignoring it is equally correct, for a different reason. Scoring this as a failure either way would accuse one of those two clients of a violation it did not commit, so the report says which answer was given rather than grading it.4498discretionaryRFC 9218 §7.2
h-extended-connectSETTINGS_ENABLE_CONNECT_PROTOCOL with a value outside 0 and 1Reject it or ignore it, but keep working. RFC 8441 §3 says the value "MUST be 0 or 1" and RFC 9220 carries that into HTTP/3 unchanged — yet neither names a behaviour for a receiver that sees anything else, so both answers are conformant and stalling is not. The setting is how a client learns Extended CONNECT is available, so a WebTransport-capable client has real parsing behind it.4499discretionaryRFC 9220 §3, RFC 8441 §3
h-push-promise-unsolicitedPUSH_PROMISE for a push the client never allowedClose the connection with H3_ID_ERROR. §7.2.7 leaves the maximum push ID unset until the client sends MAX_PUSH_ID, so a server "cannot push until it receives a MAX_PUSH_ID frame" and every push ID is larger than the client has advertised. WHERE the frame goes is the whole test. It was written to the control stream, where §7.2.5 gives a different and more specific answer -- a PUSH_PROMISE there is H3_FRAME_UNEXPECTED whatever its push ID -- so the port asked one question and this entry graded the other. Seven independent implementations answered 0x105 correctly and were failed for it, until 2026-09-20. It now arrives on the response stream, where the frame is legal and its push ID is the only thing left to object to. §7.2.5 is explicit about the answer: a client "MUST treat receipt of a PUSH_PROMISE frame that contains a larger push ID than the client has advertised as a connection error of H3_ID_ERROR".4500correctnessRFC 9114 §7.2.5, §4.6
h-datagram-setting-invalidSETTINGS_H3_DATAGRAM with a value that is neither 0 nor 1Close the connection with H3_SETTINGS_ERROR. Unusually for a setting, RFC 9297 pins down the invalid-value case rather than leaving it open: the value "MUST be either 0 or 1", and if one "is received with a value that is neither 0 nor 1, the receiver MUST terminate the connection with error H3_SETTINGS_ERROR". This is the setting that gates HTTP Datagrams and so WebTransport, which means a client that implements either has real parsing behind it rather than an ignored identifier.4501correctnessRFC 9297 §2.1.1
h-qpack-encoder-overflowEncoder stream setting a dynamic table capacity above the client's limitClose the connection with QPACK_ENCODER_STREAM_ERROR (0x201). §4.3.1 says the new capacity "MUST be lower than or equal to the limit" the decoder advertised, and that a decoder "MUST treat a new dynamic table capacity value that exceeds this limit as a connection error of type QPACK_ENCODER_STREAM_ERROR". Unlike the other two dynamic-table tests, this one runs against every client, because the capacity asked for is computed from the one that client advertised: one byte more, whatever it said. It was a fixed 4096 until 2026-09-20, which exceeded the zero every client then advertised and would have exceeded nothing at all for a client that granted a 4096-byte table.4502correctnessRFC 9204 §4.3.1, §6
h-goaway-increasingA second GOAWAY naming a larger identifier than the firstClose the connection with H3_ID_ERROR. §5.2 permits multiple GOAWAY frames but requires the identifier in each to be no greater than any previously sent, because the identifier is a promise about what will still be processed and raising it takes that promise back. "Receiving a GOAWAY containing a larger identifier than previously received MUST be treated as a connection error of type H3_ID_ERROR."4503correctnessRFC 9114 §5.2
h-push-stream-unpromisedPush stream opened for a push nobody allowedClose the connection with H3_ID_ERROR. §6.2.2 does not require the push to have been promised first — the stream alone is enough: a client "MUST treat receipt of a push stream as a connection error of type H3_ID_ERROR when no MAX_PUSH_ID frame has been sent", and no client under test sends one. Distinct from the PUSH_PROMISE test, which puts the same violation in a frame on the control stream. This one opens the push stream itself, which a client has to recognise by its stream type before any frame inside it is read.4504correctnessRFC 9114 §6.2.2
h-qpack-static-index-invalidField line indexing a static table entry that does not existClose the connection with QPACK_DECOMPRESSION_FAILED (0x200). The static table has 99 entries, so index 200 refers to nothing, and §3.1 is explicit: "When the decoder encounters an invalid static table index in a field line representation, it MUST treat this as a connection error of type QPACK_DECOMPRESSION_FAILED." The static table needs no permission and never changes size, so unlike the dynamic-table tests this one applies to every client.4505correctnessRFC 9204 §3.1, §4.5.2
h-qpack-encoder-bad-name-indexEncoder instruction naming a static index that does not existClose the connection with QPACK_ENCODER_STREAM_ERROR (0x201). The same bad index means different things depending on where it arrives, and §3.1 says both: on a field line it is QPACK_DECOMPRESSION_FAILED, and "if this index is received on the encoder stream, this MUST be treated as a connection error of type QPACK_ENCODER_STREAM_ERROR". Paired deliberately with the field-line version on the neighbouring port: a client that answers both with the same code has collapsed a distinction the specification draws twice in one paragraph.4507correctnessRFC 9204 §3.1, §4.3.2

QUIC layer

TestPortClassSpecification
q-version-negotiationVersion Negotiation offering a reserved version alongside v1Abandon the connection attempt, or retry with a version both ends support. §6.2 requires a client that supports only one version to abandon it rather than persist.4460correctnessRFC 9000 §6
q-retryRetry packet for source-address validationEcho the Retry token in a second Initial packet and complete the handshake.4461correctnessRFC 9000 §8.1.2
q-reserved-transport-paramReserved transport parameter (31·N+27)Ignore the unknown parameter and complete the handshake normally.4462extensibilityRFC 9000 §18.1
q-reserved-frameUnknown frame type in a 1-RTT packetClose the connection with FRAME_ENCODING_ERROR. Unlike HTTP/3, QUIC reserves no ignorable frame types — §12.4 makes an unknown frame a connection error, so ignoring it is the failure here.4463correctnessRFC 9000 §12.4
q-cid-rotationNEW_CONNECTION_ID followed by RETIRE_CONNECTION_IDAdopt the new connection ID, retire the old one, and stay connected.4464resilienceRFC 9000 §5.1
q-stateless-resetStateless resetRecognise the token in the last 16 bytes of the datagram, enter the draining period, and send no further packets on the connection. The packet cannot be authenticated, so there is nothing to reply with and nothing to report to the peer — continuing to send is the failure §10.3.1 names.4465correctnessRFC 9000 §10.3
q-flow-controlDeliberately tight MAX_DATA and MAX_STREAM_DATARespect the limit. Announcing the stall with DATA_BLOCKED or STREAM_DATA_BLOCKED is a SHOULD in §4.1, not a MUST, so a client that stays silent is still conformant.4466discretionaryRFC 9000 §4
q-ack-frequencyACK Frequency extension offeredNegotiate the extension, or ignore it. Either is correct; failing is not.4467discretionarydraft-ietf-quic-ack-frequency
q-ecnPackets marked ECT(0)Echo the ECN counts back in ACK frames carrying an ECN section (type 0x03). §13.4.1 makes this a conditional requirement — an endpoint MUST report the markings it receives "if these are accessible", and explicitly permits an endpoint with no access to the ECN field to report nothing. So counts coming back is a pass, and silence is never a failure. Silence is read against the path rather than left unresolved. Both directions cross the same path: a client whose own datagrams reach this endpoint still carrying ECT has shown the codepoint survives and that its stack sets it, and so has a port where any peer has ever echoed the markings sent from it. Either makes the silence the client's own and the result `unsupported` -- a property of the client, which §13.4.1 expressly allows. Only where nothing has shown the codepoint surviving is the run inconclusive, because only there is a stripped path still a live possibility.4468correctnessRFC 9000 §13.4
q-pmtu-blackholePath MTU black hole above a thresholdDetect the black hole, probe down to a working size, and keep the connection. Path-MTU discovery is driven past the limit on this port, so it engages on every connection.4469resilienceRFC 9000 §14, RFC 8899
q-path-challengeServer-initiated PATH_CHALLENGEReply with PATH_RESPONSE carrying the identical 8-byte payload.4470correctnessRFC 9000 §8.2
q-zero-rtt-reject0-RTT rejected after the client sends early dataReset the state of every stream, including application state bound to them. Section 4.6.2 requires the reset because a rejected 0-RTT means every assumed connection characteristic may have been wrong. It does not require retransmission, which is the application concern, not QUIC's. This port issues tickets that advertise early data and then declines every offer, so a resuming client sends 0-RTT and always has it refused. The handshake itself completes normally.4471resilienceRFC 9001 §4.6.2
q-multipathA second path offered mid-connectionUse the additional path, or decline it cleanly. Do not abort the connection.4472discretionarydraft-ietf-quic-multipath
q-key-updateSpontaneous 1-RTT key updateUpdate to the next key phase and carry on. Once a packet protected with the next phase is processed, §6.2 is a MUST — "The endpoint MUST update its send keys to the corresponding key phase in response" — so the response written after the update has to be read with the new keys and the request completed.4487interoperabilityRFC 9001 §6.2
q-stream-limitStream limits set to the minimum a request needsStay inside the advertised limits. Respecting them is a MUST — §4.6 says "Endpoints MUST NOT exceed the limit set by their peer" — but announcing the stall with STREAMS_BLOCKED is only a SHOULD, so a client that stays silent is still conformant.4488discretionaryRFC 9000 §4.6
q-loss-recoveryOne datagram in twelve dropped once the path is establishedReassemble the stream and deliver the whole body. §2.2 requires an endpoint to buffer data received out of order and deliver it as an ordered byte stream, and §13.3 has the lost data sent again in new STREAM frames — so a response full of gaps must still arrive complete and in order.4489resilienceRFC 9000 §2.2, §13.3
q-connection-migrationDatagrams arriving from a second server addressKeep using the address you are already talking to. §9.6 says a client "SHOULD ignore packets received from a server address other than the one it is currently using for sending packets" — a SHOULD, so quietly discarding them and objecting are both conformant. Following the new address is not: nothing is listening there, and a server may only move a client to an address it advertised as its preferred one.4495discretionaryRFC 9000 §9.6
q-invalid-transport-paramTransport parameter carrying a value the specification forbidsClose the connection with TRANSPORT_PARAMETER_ERROR. §18.2 makes an ack_delay_exponent above 20 invalid, and §7.4 is a MUST: "An endpoint MUST treat receipt of a transport parameter with an invalid value as a connection error of type TRANSPORT_PARAMETER_ERROR." Distinct from a *duplicate* parameter, which the same clause makes only a SHOULD — this port sends one parameter, once, with a value outside its permitted range. The parameter travels in the handshake, so every client that connects here reads it. Only a client that answers with a CONNECTION_CLOSE this endpoint can read is scored: one that simply abandons the handshake is recorded as inconclusive, because a close that was sent and lost looks exactly like one that was never sent.4496correctnessRFC 9000 §7.4, §18.2
q-zero-rtt-replay425 (Too Early) in answer to a request sent as early dataHandle being told the request arrived too early. §5.2 says a user agent "SHOULD retry automatically, but any retries MUST NOT be sent in early data" — so retrying on the 1-RTT keys and handing the 425 back to the caller are both conformant, and the report says which happened. Falling over is not one of the options. This is the only port that *accepts* early data instead of refusing it, which is what makes the 425 exchange possible at all. What it does not check is §4's rule that unsafe methods must never be sent in early data: reading the method would mean QPACK-decoding the request, which this suite deliberately never does. Reaching it needs a session ticket from an earlier connection to this same port, so a client that connects once has none.4497discretionaryRFC 8470 §5.2
q-ecn-congestionPath marking packets CE, not just ECT(0)Report the CE count back in the ECN section of an ACK. §13.4.1 makes this the same conditional requirement as reporting ECT(0) — an endpoint MUST provide feedback about the markings it receives "if these are accessible" — and CE is the marking that matters, since it is how a path says it is congested. Distinct from the ECT(0) test on the neighbouring port: that one asks whether a client reports markings at all, and this asks whether it distinguishes the one that means something. A client that echoes ECT(0) counts faithfully and never reports a CE has a congestion signal it cannot see. Silence is never a failure, for the same reason, and it is read against the path in the same way: where this client's own datagrams arrived carrying ECT, or where any peer has echoed the markings this port sent, the codepoint demonstrably survives and the silence is the client's own -- `unsupported`, which §13.4.1 expressly allows. Only where nothing has shown the codepoint surviving is a stripped path still possible, and only there is the run inconclusive.4508correctnessRFC 9000 §13.4
q-key-update-repeatedA second key update after the first is acknowledgedFollow both updates and carry on. §6.1 lets an endpoint update again once the previous phase has been acknowledged, so a long-lived connection changes keys repeatedly and a client has to track the phase rather than assume one change. Distinct from the single update on the neighbouring port. An implementation that hardcodes the first transition — treating key phase as a one-way flag rather than a bit that alternates — passes that test and fails here, which is precisely the bug worth finding.4509interoperabilityRFC 9001 §6.1, §6.5
q-max-streams-creditNo stream credit at first, then MAX_STREAMS mid-connectionWait for the credit, then open the request. This port grants no bidirectional streams in its transport parameters and issues MAX_STREAMS a moment later. §4.6 is unambiguous that a client may not jump the gun — "Endpoints MUST NOT exceed the limit set by their peer" — and announcing the wait with STREAMS_BLOCKED is a SHOULD, so a client that waits quietly is equally conformant. Giving up rather than waiting is not scored as a failure: nothing obliges a one-shot client to sit on a connection it cannot use yet, and the report says which it did.4510discretionaryRFC 9000 §4.6
q-packet-reorderingDatagrams delivered out of orderPut the stream back in order and deliver the whole body. §2.2 requires an endpoint to be "able to deliver stream data to an application as an ordered byte stream", and says plainly that doing so "requires that an endpoint buffer any data that is received out of order". Distinct from the loss test, which needs retransmission before the gap can be filled. Nothing is lost here: every byte arrives, some of it early, and a client that assumes arrival order is delivery order will produce a corrupt body or stall waiting for data it already has.4506resilienceRFC 9000 §2.2

TLS layer

TestPortClassSpecification
t-hybrid-onlyServer negotiates only the post-quantum hybrid X25519MLKEM768Complete the handshake. A client whose first key share was classical must recover through HelloRetryRequest, which §4.1.4 requires it to answer with a second ClientHello carrying a share for the named group. A client that does not offer the group at all must abandon the attempt cleanly rather than stall — that is a correct outcome for a client without post-quantum support. A client that cannot negotiate the group at all never establishes a connection, so the server records no verdict and the result is `not run` rather than a pass or a failure. On this tier that reads as "no post-quantum key exchange", which is itself the finding: every other tier reaches `not run` only when the runner skipped something. curl/ngtcp2 1.11.0 lands here. This is the round trip that breaks first in a real migration, because it is the one that never happens until a server somewhere stops offering the classical group.4511interoperabilityRFC 8446 §4.1.4, draft-ietf-tls-hybrid-design
t-classical-onlyServer negotiates only classical X25519 against a hybrid offerEither outcome is conformant and the report says which was taken. A client that proceeds has chosen availability: the connection is classically secure and it accepted that. A client that refuses has chosen a post-quantum floor, which is a policy some deployments now require and no RFC yet mandates. No grade is attached, because attaching one would invent a requirement. What is worth knowing is that the answer is a deliberate choice rather than an accident, and today most clients cannot express it either way.4512discretionaryRFC 8446 §4.1.1, draft-ietf-tls-hybrid-design §5
t-hybrid-large-helloHybrid key share large enough to split the Initial across packetsComplete the handshake with a ClientHello that does not fit one QUIC Initial packet. An ML-KEM-768 key share is 1,216 bytes, which pushes a ClientHello past the 1,200-byte floor RFC 9000 §14.1 sets for an Initial, so the flight must be spread over more than one packet and every one of them padded to the full size. This is the concrete reason post-quantum TLS deployments fail in the field, and it interacts with the §8.1 anti-amplification limit: the server may not send more than three times what it has received, so a client that under-pads its Initials can stall the handshake without either side doing anything invalid.4513resilienceRFC 9000 §8.1, §14.1, RFC 9001 §4.4
t-group-not-offeredServer selects a key exchange group the client never offeredAbort with illegal_parameter. §4.1.3 is explicit that if the selected group was not offered, the client MUST abort — accepting it would let a server steer a client onto a group it deliberately excluded. The port names secp384r1 in its ServerHello against a client that offered only the hybrid, and leaves the payload exactly as generated: the only thing wrong is the name, so a client that aborts can only be aborting over §4.1.3 and not over a share it could not parse. What is judged is the abort, not the alert value. RFC 9001 §4.8 lets a QUIC endpoint replace any alert with a generic one — handshake_failure in place of illegal_parameter — expressly so that a client need not say what it objected to, so the code a client chooses is reported here and not scored. A handshake that ends without any CONNECTION_CLOSE is inconclusive rather than a failure: the group was certainly not accepted, but a close that was never sent cannot be told from one that was lost.4514correctnessRFC 8446 §4.1.3, §4.2.8, RFC 9001 §4.8
t-corrupt-hybrid-shareHybrid key share with an intact X25519 half and a corrupt ML-KEM halfFail the handshake. The hybrid secret is the concatenation of both shares fed through the key schedule, so corrupting either half must produce a transcript mismatch and a failed Finished verification. What is being looked for is the failure mode, not the failure: a client that falls back to the classical half alone has silently downgraded itself to exactly the security level the hybrid exists to avoid, and would do so against an attacker who can corrupt one half at will. The server share for X25519MLKEM768 is the 1,088-byte ML-KEM ciphertext followed by the 32-byte X25519 key. One bit is flipped early in the ciphertext and the classical tail is left untouched, so a client that still completes has used the classical half alone. A single bit rather than a scribble on purpose: ML-KEM decapsulation never fails, it returns an implicit-rejection secret, so the handshake has to die at Finished verification rather than at a decode error — damaging the length or the structure would test the parser instead.4515correctnessdraft-ietf-tls-hybrid-design §3.2, RFC 8446 §4.1.3
t-hybrid-share-lengthHybrid key share whose length does not match the group named with itAbort rather than proceed. §3.1.2 is explicit: "For all groups, the client MUST check if the ciphertext length matches the selected group, and abort with an illegal_parameter alert if it fails." The port sends 1,088 bytes where X25519MLKEM768 fixes the server share at 1,120 — the ML-KEM ciphertext whole and the 32-byte X25519 tail removed. It parses cleanly as an opaque vector, so the only thing wrong with it is its length for the group it is named with. Neither outcome lets a client derive our keys, because the shared secret needs both halves. What separates them is where it notices: a client that checks the length rejects this at the ServerHello, while one that does not carries a truncated share into decapsulation and fails later and less clearly. What is judged is the abort, not the alert value. RFC 9001 §4.8 lets a QUIC endpoint replace any alert with a generic one, so the code a client chooses is reported and not scored — and a handshake that ends with no CONNECTION_CLOSE at all is inconclusive rather than a failure, because a close that was never sent cannot be told from one that was lost.4517correctnessdraft-kwiatkowski-tls-ecdhe-mlkem §3.1.2, RFC 8446 §6.2, RFC 9001 §4.8
t-cert-compression-pqPost-quantum certificate chain, compressed per RFC 8879Decompress and parse an ML-DSA-87 chain, then judge it on its merits. Nothing here is graded: RFC 8879 is optional, no RFC requires support for ML-DSA certificates, and §4 expressly lets a receiver cap the decompressed size and abort. What the port reports is which of those a client does. The chain is deliberately issued by a private CA nobody trusts, and that is what makes the measurement work rather than spoiling it. A client that rejects it for its *trust anchor* — unknown_ca, or bad_certificate — has already decompressed a certificate message several times the size of a classical one, parsed ML-DSA-87 structures it may never have seen and got as far as chain building. That is the whole capability under test, and the rejection that follows is correct behaviour, not a failure. A client that cannot get that far answers differently: decode_error or a record-size abort says the compressed chain itself defeated it, which is the outcome the post-quantum migration needs to know about. Certificate sizes are the half of that migration nobody can configure their way out of — ML-DSA-87 signatures are 4,627 bytes each and every chain carries several.4518discretionaryRFC 8879 §4, RFC 8446 §4.4.2
t-grease-groupGREASE named group a client must tolerateIgnore the unrecognised group and complete the handshake. RFC 8701 reserves these values precisely so that an endpoint meeting one learns to tolerate a future real group sharing the same shape. The value goes in the server's supported_groups in EncryptedExtensions, second in a list of real groups. RFC 8446 §4.2.7 permits a server to send that list — "regardless of whether they are currently supported by the client" — and requires that a client "MUST NOT act upon any information found in supported_groups prior to successful completion of the handshake". So the handshake must complete, and a client that aborts over a codepoint it was told not to act on has ossified against groups that do not exist yet. WHERE the value goes is the whole test, and getting it wrong the first time is on the record deliberately. It was built by naming a GREASE value in the ServerHello key_share, which is not an extensibility test at all: §4.1.3 makes a key_share naming any group the client did not offer illegal whatever the value is, so every conformant client correctly answered illegal_parameter — and an Extensibility test demanding tolerance scored all of them as failures. neqo caught it on first contact. It also duplicated t-group-not-offered, which measures that rejection properly. The honest version needed an extension rustls had no reason to send, so the fork's ServerExtensions gained a named_groups field. The port and the id never moved while it was unimplemented.4516extensibilityRFC 8701 §4, RFC 8446 §4.2.7

Scope, and what this is not

59 tests is a beginning, not coverage. QUIC and HTTP/3 together are enormous, and the catalogue is chosen rather than exhaustive. What is missing divides into two different things, and collapsing them into one list of "not done yet" would overstate what this suite can ever become.

Testable, and not yet written. Shorter than it was: ECN congestion marking, repeated key updates and mid-connection MAX_STREAMS credit have since been built. What is left is a Retry packet a client ought to discard — §17.2.5.2 has a client ignore any Retry after the first — and similar single-clause cases. Compatible version negotiation is not counted here: §6.2 does not say what a client does when the offered version is one it supports, only that "how to perform version negotiation is left as future work". RFC 9368 supplies that work, so the test becomes possible once the stack implements version_information, and not before.

Outside what this vantage point can establish. Not a backlog. A server watching a client cannot legitimately provoke or observe these, so a test claiming to cover them would be reporting on something other than what it says. QPACK decoder-stream errors, because that stream is the client's output and nothing here can make it emit a malformed instruction. WebTransport session handling, because Extended CONNECT is client-initiated and a generic HTTP/3 client never opens a session. A client's own migration after a NAT rebind, because only the client or its network can change the address it sends from — the server can send a PATH_CHALLENGE, and does, but that is a different question. And the amplification limit, which is a requirement on servers, so there is nothing about it to ask of the thing under test.

Why this exists

The QUIC Interop Runner tests implementations against each other in a lab matrix. This is the other thing: a public endpoint you can point a client at. Offering one requires a server that will misbehave on demand, which is not something a CDN customer can arrange — they do not own the implementation. This one runs on a forked QUIC and HTTP/3 stack, which is what makes the awkward cases possible.

If your client scores a failure you believe is wrong, say so — a suite that accuses the thing it tests is worth fixing quickly.