PQ CRYPTA PLATFORM

๐Ÿ  Main

๐Ÿ“ฐ News

๐Ÿ‘ค Account

โŸจ QUANTUM ERROR PORTAL โŸฉ

Navigate the Error Dimensions

PQ Crypta Logo

HTTP/3 Client Conformance

A deliberately broken QUIC and HTTP/3 server, for testing clients

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

Point a client library at it and the server misbehaves on purpose — a reserved frame type, a duplicated setting, a control stream opening with the wrong frame — then records how your client answered. Every verdict comes from what the server observed, because a library that crashes on an unknown frame is in no position to report on itself. Free, no account, open to any client.

The QUIC Interop Runner tests implementations against each other in a lab matrix. This is the other thing: the awkward corners, on demand, against your client.

Results for several well-known implementations are published in the client matrix — what curl, Chromium, quic-go, aioquic, quinn and Cloudflare quiche each do when the server misbehaves. The same results are browsable one fact at a time: a page per implementation, a page per anomaly, and a page for every individual verdict, each naming the clause it was judged against.

Unlike an interoperability test, this one deliberately sends your client protocol edge cases, illegal messages, GREASE, awkward state transitions and hostile network conditions — and judges the result from the wire, not from what the client reports about itself.

Offering one requires a server that will misbehave on demand โ€” emit a reserved frame type, duplicate a SETTINGS identifier, open a control stream with the wrong frame. That is not something a CDN customer can arrange, because they do not own the implementation. We fork the QUIC stack, so we can.

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

What the catalogue covers

HTTP/3

28 tests

correctness
14
interoperability
5
discretionary
4
extensibility
3
resilience
2

QUIC

23 tests

correctness
8
discretionary
7
resilience
5
interoperability
2
extensibility
1

TLS

8 tests

correctness
3
discretionary
2
interoperability
1
resilience
1
extensibility
1

What a result means

PassThe client demonstrated the behaviour the specification requires.
FailThe client did something the specification prohibits.
UnsupportedThe client does not do this, and said so itself โ€” by what it advertised, or by refusing outright. A definite answer about the client, not a failure and not a gap.
InconclusiveThe suite could not establish what this client does here. That is a shortcoming of the run rather than anything about the client, and every one of these is on our list to remove. The published matrix has none.

Running it

Each test has its own UDP port. Connect to it, and the server records what your client did. Results are filed under a session started from your address, so the test connections need nothing special.

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, and the session id is the first "id" in the /session response.

The report is also available as a page at /report/<session> and as 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 the 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.
--timeout30Seconds 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.

Embedding the badge

![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.

How a verdict is reached

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 this is the whole game โ€” "ignored it" and "died on it" look identical on the wire up to that point, and without the probe a crashed client would read as compliant.

A failure needs positive evidence. It is tempting to read “no rejection arrived” as “the client accepted it”, and that inference is unsound. Where the anomaly rides in the response your client had to read to be served, a completed exchange does show it was seen. Where it went to the control stream — unidirectional, and read on no particular schedule — a one-shot request can finish before the stream is picked up at all, which looks identical to accepting the violation. The same is true of a rejection whose CONNECTION_CLOSE is lost in transit, since QUIC only repeats that frame in answer to an incoming packet. Neither case is reported as a failure.

That last case is asked about rather than assumed. RFC 9000 ยง10.2.1 has a closing endpoint re-send its CONNECTION_CLOSE in answer to an incoming packet — and only then — so where a connection ended with no reason recorded the server sends a PING and waits. A client that did object answers with the close it already sent; a client that never objected stays quiet, and now the silence is evidence instead of an absence of it.

In the published run that probe was needed 136 times. It drew a close 36 times and a transport acknowledgement 53 times, so the question demonstrably arrived in 65% of them.

Both numbers are needed to read either. A peer that acknowledges the PING and sends no close is alive, which is what makes “no close observed” a measurement rather than an assumption. A probe that drew neither would have reached nobody, and the same silence would mean this instrument is broken — the identical mistake the probe was added to stop making, one level up. So the suite counts its own question as well as the answer.

The distinction is carried down to the individual result, not just kept in this total. A correctness cell whose peer acknowledged the probe says so, and says the client was still there and had not rejected the anomaly; one whose peer answered nothing says the weaker thing instead, that a client which exited without closing looks from here exactly like one that read the anomaly and carried on. The two were a single sentence until 2026-09-19, and it made the stronger claim in both.

The classes exist because 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 precisely 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, so a single pass/fail rule would score one of them backwards.

Extensibility

Ignore something unrecognised and carry on. Rejecting it is the failure โ€” that is how protocols ossify.

Correctness

Reject something invalid, with the specific error code the RFC names. Accepting it is the bug — but a failure here is only recorded when your client demonstrably read the anomaly, never merely because no rejection arrived.

Interoperability

Correctly decode something valid but demanding โ€” Huffman-coded fields, a dynamic-table reference, trailers.

Resilience

Recover โ€” retry, re-probe, migrate โ€” rather than give up.

Discretionary

The RFC permits more than one answer. Both pass; the report says which yours chose.

Inconclusive

The run never put your client in the situation the test is about. It proves nothing either way, and says so.

The catalogue

Read live from the suite, so this table cannot drift from what is actually served. Every entry cites the clause it exercises.

HTTP/3 layer

Test Port Class Specification
h-grease-settings Reserved SETTINGS identifier (0x1fยทN+0x21) Ignore the unknown setting and complete the request. 4473 Extensibility RFC 9114 ยง7.2.4.1
h-grease-frame Reserved frame type on the response stream Skip the frame using its length and read the response that follows. 4474 Extensibility RFC 9114 ยง7.2.8
h-reserved-uni-stream Unidirectional stream with a reserved stream type Abort 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. 4475 Extensibility RFC 9114 ยง6.2.3
h-duplicate-setting SETTINGS containing the same identifier twice Either reject with H3_SETTINGS_ERROR or ignore the repeat โ€” the specification says a receiver MAY treat this as an error, so both are conformant. 4476 Discretionary RFC 9114 ยง7.2.4
h-control-frame-unexpected A DATA frame on the control stream Close the connection with H3_FRAME_UNEXPECTED. 4477 Correctness RFC 9114 ยง7.2.1
h-missing-settings Control stream whose first frame is not SETTINGS Close the connection with H3_MISSING_SETTINGS. 4478 Correctness RFC 9114 ยง6.2.1
h-second-control-stream A second control stream opened by the server Close the connection with H3_STREAM_CREATION_ERROR. 4479 Correctness RFC 9114 ยง6.2.1
h-qpack-dynamic-table Field lines referencing dynamic table insertions Apply the encoder-stream insertions and decode the headers correctly. 4480 Interoperability RFC 9204 ยง4.3.3, ยง4.5.2
h-qpack-huffman Huffman-coded field lines with maximal padding Decode without error. Padding of up to 7 bits is legal, not corruption. 4481 Interoperability RFC 9204 ยง4.1.2, RFC 7541 ยง5.2
h-oversized-field-section Field section larger than the client's advertised maximum Handle it as an error against that one request, not the whole connection. 4482 Interoperability RFC 9114 ยง4.2.2
h-trailers Trailing field section after the body Deliver the trailers to the application after the body completes. 4483 Interoperability RFC 9114 ยง4.1
h-early-hints 103 Early Hints before the final response Treat 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. 4484 Resilience RFC 9110 ยง15.2, RFC 8297
h-goaway GOAWAY sent mid-connection Stop 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. 4485 Resilience RFC 9114 ยง5.2
h-max-push-id MAX_PUSH_ID sent by the server Reject 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. 4486 Correctness RFC 9114 ยง7.2.7
h-settings-on-request-stream SETTINGS frame on a request stream Close 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". 4490 Correctness RFC 9114 ยง7.2.4
h-data-before-headers DATA frame before any HEADERS on the response stream Close 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. 4491 Correctness RFC 9114 ยง4.1
h-cancel-push-unsolicited CANCEL_PUSH for a push ID that was never promised Close 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. 4492 Correctness RFC 9114 ยง7.2.3
h-qpack-blocked-stream Field section that blocks until the encoder stream catches up Hold 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. 4493 Interoperability RFC 9204 ยง2.1.2, ยง2.2.1
h-response-stream-reset Response stream reset mid-body with H3_REQUEST_CANCELLED Abandon 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. 4494 Discretionary RFC 9114 ยง8, ยง4.1
h-priority-update PRIORITY_UPDATE sent by the server Reject 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. 4498 Discretionary RFC 9218 ยง7.2
h-extended-connect SETTINGS_ENABLE_CONNECT_PROTOCOL with a value outside 0 and 1 Reject 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. 4499 Discretionary RFC 9220 ยง3, RFC 8441 ยง3
h-push-promise-unsolicited PUSH_PROMISE for a push the client never allowed Close 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". 4500 Correctness RFC 9114 ยง7.2.5, ยง4.6
h-datagram-setting-invalid SETTINGS_H3_DATAGRAM with a value that is neither 0 nor 1 Close 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. 4501 Correctness RFC 9297 ยง2.1.1
h-qpack-encoder-overflow Encoder stream setting a dynamic table capacity above the client's limit Close 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. 4502 Correctness RFC 9204 ยง4.3.1, ยง6
h-goaway-increasing A second GOAWAY naming a larger identifier than the first Close 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." 4503 Correctness RFC 9114 ยง5.2
h-push-stream-unpromised Push stream opened for a push nobody allowed Close 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. 4504 Correctness RFC 9114 ยง6.2.2
h-qpack-static-index-invalid Field line indexing a static table entry that does not exist Close 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. 4505 Correctness RFC 9204 ยง3.1, ยง4.5.2
h-qpack-encoder-bad-name-index Encoder instruction naming a static index that does not exist Close 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. 4507 Correctness RFC 9204 ยง3.1, ยง4.3.2

QUIC layer

Test Port Class Specification
q-version-negotiation Version Negotiation offering a reserved version alongside v1 Abandon 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. 4460 Correctness RFC 9000 ยง6
q-retry Retry packet for source-address validation Echo the Retry token in a second Initial packet and complete the handshake. 4461 Correctness RFC 9000 ยง8.1.2
q-reserved-transport-param Reserved transport parameter (31ยทN+27) Ignore the unknown parameter and complete the handshake normally. 4462 Extensibility RFC 9000 ยง18.1
q-reserved-frame Unknown frame type in a 1-RTT packet Close 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. 4463 Correctness RFC 9000 ยง12.4
q-cid-rotation NEW_CONNECTION_ID followed by RETIRE_CONNECTION_ID Adopt the new connection ID, retire the old one, and stay connected. 4464 Resilience RFC 9000 ยง5.1
q-stateless-reset Stateless reset Recognise 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. 4465 Correctness RFC 9000 ยง10.3
q-flow-control Deliberately tight MAX_DATA and MAX_STREAM_DATA Respect 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. 4466 Discretionary RFC 9000 ยง4
q-ack-frequency ACK Frequency extension offered Negotiate the extension, or ignore it. Either is correct; failing is not. 4467 Discretionary draft-ietf-quic-ack-frequency
q-ecn Packets 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. 4468 Correctness RFC 9000 ยง13.4
q-pmtu-blackhole Path MTU black hole above a threshold Detect 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. 4469 Resilience RFC 9000 ยง14, RFC 8899
q-path-challenge Server-initiated PATH_CHALLENGE Reply with PATH_RESPONSE carrying the identical 8-byte payload. 4470 Correctness RFC 9000 ยง8.2
q-zero-rtt-reject 0-RTT rejected after the client sends early data Reset 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. 4471 Resilience RFC 9001 ยง4.6.2
q-multipath A second path offered mid-connection Use the additional path, or decline it cleanly. Do not abort the connection. 4472 Discretionary draft-ietf-quic-multipath
q-key-update Spontaneous 1-RTT key update Update 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. 4487 Interoperability RFC 9001 ยง6.2
q-stream-limit Stream limits set to the minimum a request needs Stay 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. 4488 Discretionary RFC 9000 ยง4.6
q-loss-recovery One datagram in twelve dropped once the path is established Reassemble 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. 4489 Resilience RFC 9000 ยง2.2, ยง13.3
q-connection-migration Datagrams arriving from a second server address Keep 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. 4495 Discretionary RFC 9000 ยง9.6
q-invalid-transport-param Transport parameter carrying a value the specification forbids Close 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. 4496 Correctness RFC 9000 ยง7.4, ยง18.2
q-zero-rtt-replay 425 (Too Early) in answer to a request sent as early data Handle 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. 4497 Discretionary RFC 8470 ยง5.2
q-ecn-congestion Path 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. 4508 Correctness RFC 9000 ยง13.4
q-key-update-repeated A second key update after the first is acknowledged Follow 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. 4509 Interoperability RFC 9001 ยง6.1, ยง6.5
q-max-streams-credit No stream credit at first, then MAX_STREAMS mid-connection Wait 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. 4510 Discretionary RFC 9000 ยง4.6
q-packet-reordering Datagrams delivered out of order Put 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. 4506 Resilience RFC 9000 ยง2.2

TLS layer

Test Port Class Specification
t-hybrid-only Server negotiates only the post-quantum hybrid X25519MLKEM768 Complete 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. 4511 Interoperability RFC 8446 ยง4.1.4, draft-ietf-tls-hybrid-design
t-classical-only Server negotiates only classical X25519 against a hybrid offer Either 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. 4512 Discretionary RFC 8446 ยง4.1.1, draft-ietf-tls-hybrid-design ยง5
t-hybrid-large-hello Hybrid key share large enough to split the Initial across packets Complete 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. 4513 Resilience RFC 9000 ยง8.1, ยง14.1, RFC 9001 ยง4.4
t-group-not-offered Server selects a key exchange group the client never offered Abort 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. 4514 Correctness RFC 8446 ยง4.1.3, ยง4.2.8, RFC 9001 ยง4.8
t-corrupt-hybrid-share Hybrid key share with an intact X25519 half and a corrupt ML-KEM half Fail 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. 4515 Correctness draft-ietf-tls-hybrid-design ยง3.2, RFC 8446 ยง4.1.3
t-hybrid-share-length Hybrid key share whose length does not match the group named with it Abort 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. 4517 Correctness draft-kwiatkowski-tls-ecdhe-mlkem ยง3.1.2, RFC 8446 ยง6.2, RFC 9001 ยง4.8
t-cert-compression-pq Post-quantum certificate chain, compressed per RFC 8879 Decompress 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. 4518 Discretionary RFC 8879 ยง4, RFC 8446 ยง4.4.2
t-grease-group GREASE named group a client must tolerate Ignore 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. 4516 Extensibility RFC 8701 ยง4, RFC 8446 ยง4.2.7

When a run proves nothing

Every port in the catalogue emits its anomaly—59 of 59. Nothing here is a placeholder.

Whether your run reaches a given anomaly is a separate question, and some depend on what your client happens to do. A few examples, all of which report inconclusive rather than guessing:

An inconclusive result says which of these applied. It is never counted as a pass and never as a failure, and it does not fail a CI run.

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

How much of this moves when nothing does

A verdict that changes between two runs of the same binaries is not a verdict, and a suite that never measures that is asking to be taken on faith. 3 consecutive full runs were kept for this — same server build, same client binaries, same catalogue, nothing changed between them — and every cell was compared across all 3.

Every one of the 708 cells held one verdict across all 3 runs. Getting there took closing a series of races in the harness itself — a count taken per address that two connections could share, a fact recorded only when a connection's handler finished, a probe that could lose to the client's own acknowledgement — each of which had shown up as one cell reading differently from one run to the next.

This is measured, not asserted, and it is recomputed from the kept runs every time this page is served. If the runs are removed the section disappears rather than quoting a number nobody can check.

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 quite 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

The backlog is 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 — RFC 9000 ยง17.2.5.2 has a client ignore any Retry after the first — and a handful of similar single-clause cases.

Compatible version negotiation is a case of its own, and no longer counted as backlog. RFC 9000 ยง6.2 does not define what a client does when the offered version is one it supports; it says 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. Writing it now would mean measuring behaviour no clause requires, which is how a conformance suite starts inventing requirements.

Outside what this vantage point can establish

Not a backlog, and listing it as one would be misleading. These are behaviours a server watching a client cannot legitimately provoke or observe, so a test claiming to cover them would be reporting on something other than what it says.