b097580fdd
Round 10 — supersedes the typed-exception approach from round 9. Round 9 introduced [PskRejectedException] as a "punt to the application layer" hack, with a comment claiming the in-place fallback was too subtle to land safely. After tracing the actual derivation path the fix turns out to be one line. The key observation: the on-wire ClientHello (with `pre_shared_key` extension and binder bytes) goes into BOTH client and server transcript hashes regardless of accept / reject (RFC 8446 §4.2.11). The transcript hash itself doesn't need any rebuild. The ONLY thing that differs between accept and reject is how [earlySecret] is derived: * accepted: HKDF-Extract(0, PSK) * rejected: HKDF-Extract(0, 0) ← same as no-resumption path So when the server returns ServerHello without `pre_shared_key`, we simply call `keySchedule.deriveEarly()` to overwrite the PSK-derived [earlySecret] with the zero-keyed value. [deriveHandshake] runs immediately after with the new earlySecret + ECDHE shared secret, and the handshake proceeds along the regular non-resumption path (which is well-tested by every non-resumption test in the suite). * `pskAccepted = false` so the WAITING_CERTIFICATE_OR_FINISHED state correctly demands Certificate + CertificateVerify (a Finished without those would still be rejected as unauthenticated). * Any 0-RTT packets the application emitted under the PSK-derived [clientEarlyTrafficSecret] are lost — server can't decrypt them and EncryptedExtensions arrives without the early_data extension, so [earlyDataAccepted] = false. The application layer is responsible for replaying any 0-RTT-bound payload over 1-RTT. The handshake itself proceeds cleanly. * [PskRejectedException] (added in round 9) deleted as dead code. [QuicCodecException] reverts to a `final` class. All 269 :quic:jvmTest tests pass. The fallback re-uses the deriveEarly() codepath that every non-resumption test exercises, so test coverage is implicit in the existing suite. https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM