Files
amethyst/nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md
T
Claude dfdf07269d docs(nestsclient): record L1/L2/L3 closures in moq-lite Lite-03/04 audit
Adds Fix #6 (L2 — SubscribeOk narrowing), Fix #7 (L3 —
subscriber-driven Probe API), and Fix #8 (L1 — version-aware
Lite-03/04 codec + ALPN negotiation) to the compliance audit
document. Updates the gap matrix (L1/L2/L3 → ) and the TL;DR
(now reads "nine shipped fixes + M6 closed; every gap is now
resolved, no items remain deferred").

The "what's deliberately deferred" section now lists only the
external follow-up — a `kixelated/moq` feature request
suggesting per-deployment tuning of moq-rs's per-subscriber
forward-queue starvation behavior. That's an upstream-relay
concern, not a moq-lite gap.

Updates the audio-rooms completion-plan pointer to reflect the
shipped count and the absence of remaining deferrals. Audit doc
title acknowledges Lite-04 alongside Lite-03 since the codec
now speaks both.

https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
2026-05-09 15:08:12 +00:00

38 KiB
Raw Blame History

moq-lite Lite-03 compliance audit

Date: 2026-05-09 Branch: claude/audit-moq-lite-compliance-NSuPk Scope: nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/ plus the audio glue under …/audio/ that drives moq-lite. The IETF draft-ietf-moq-transport-17 reference impl in …/moq/ (no lite/) is OUT of scope — it stays as a unit-test target only. The QUIC layer (:quic) is OUT of scope; recently audited and locked.

TL;DR

The moq-lite Lite-03 implementation is wire-compatible with kixelated's reference relay (moq-rs / moq-lite-03). All on-the-wire codec primitives — control type discriminators, AnnouncePlease, Announce, Subscribe (incl. the Option<u64> off-by-one and Duration millis-as-varint conventions), SubscribeOk/Drop (with the type-outside-size-prefix framing peculiar to Lite-03), GroupHeader, Probe — match the reference Rust impl byte-for-byte. The known gaps are all spec-loose / future-fragile (🟡 / 🟦), not wire-incompatible (🔴).

This audit shipped nine fixes (M1, M2, M3, M4, M5, L1, L2, L3, L5) and closed M6 (verified via WebFetch that Goaway has no body in the spec, so our existing handler is canonical). L4 is subsumed by the M1 fix. M2/M3 originally deferred under the ":quic is locked" constraint; after a merge from main brought in the full RFC-compliant :quic layer, the user lifted the constraint and M2/M3 landed end-to-end. L1/L2/L3 originally parked as 🟦 non-fixes; the user re-prioritised them and all three landed with regression tests. Every gap from the matrix is now resolved () or explicitly closed. No 🔴, 🟡, or 🟦 items remain open.

Methodology

  1. Walked every Kotlin file under moq/lite/ and traced data flow speaker → relay → listener.
  2. Cross-referenced each on-wire message + control byte against kixelated's reference Rust impl at https://github.com/kixelated/moq/tree/main/rs/moq-lite/src/, reading specifically:
    • coding/decode.rs, coding/encode.rs, coding/varint.rs (primitive codings — confirmed Option<u64> is 0=None / n=Some(n-1), Duration is millis-as-varint, bool is 1 byte 0/1).
    • lite/stream.rs (ControlType + DataType discriminator codes).
    • lite/announce.rs (AnnouncePlease + Announce body shape).
    • lite/subscribe.rs (Subscribe + SubscribeResponse Lite-03 framing).
    • lite/publisher.rs (Publisher::serve_group priority handle, uni-stream open + group header).
    • lite/subscriber.rs (Probe direction, group decode loop, unsubscribe = FIN).
    • lite/probe.rs (Probe body: bitrate u62 only on Lite-03).
  3. Verified the team's prior 2026-04-26-moq-lite-gap.md against live source — every claimed item still holds (see "Verified working" below).
  4. Built the gap matrix (see "Gap matrix" below).
  5. Shipped fixes for the two 🟡 items closest to being 🔴 — see "Fixes shipped" at bottom.
  6. Ran :nestsClient:jvmTest after each fix — green throughout.

What's verified working

The shipped Lite-03 surface holds up against the reference Rust impl at the byte / control-flow level:

Surface Verdict Evidence (file:line)
ALPN "moq-lite-03" advertised as the only sub-protocol MoqLiteAlpn.kt:47, QuicWebTransportFactory.kt:88-113
No SETUP / no in-band session message (Lite-03 = WT handshake IS the handshake) MoqLiteSession.kt:1374-1378 (client(...) returns immediately, no SETUP coroutine)
ControlType codes — Session=0, Announce=1, Subscribe=2, Fetch=3, Probe=4, Goaway=5 MoqLiteControlCodes.kt:39-65 (all five recognised + dispatched in handleInboundBidi)
DataType::Group=0 on uni-stream open MoqLiteControlCodes.kt:74-85, MoqLiteSession.kt:1047
Path normalisation (strip leading/trailing /, collapse runs) MoqLitePath.kt:44-99 (matches rs/moq-lite/src/path.rs::Path::new)
Path normalisation applied at every wire boundary MoqLiteCodec.kt:54, 70, 92 (encode side); :60, 81, 105 (decode side)
AnnouncePlease body shape — prefix: string MoqLiteCodec.kt:52-63
Announce body shape — status u8, suffix string, hops u62 varint MoqLiteCodec.kt:67-85; status enum Ended=0, Active=1 matches rs/moq-lite/src/lite/announce.rs:84-90
Subscribe body — 8 fields: id u62, broadcast string, track string, priority u8, ordered u8, maxLatency varint, startGroup, endGroup MoqLiteCodec.kt:89-100
Option<u64> off-by-one collapse: 0=None, n=Some(n-1) MoqLiteCodec.kt:286-288 matches rs/moq-lite/src/coding/decode.rs::impl Decode for Option<u64>
Duration encoded as millis-as-varint MoqLiteCodec.kt:96 matches rs/moq-lite/src/coding/encode.rs::impl Encode for std::time::Duration
SubscribeResponse framing: type discriminator OUTSIDE size prefix (Lite-03+) MoqLiteCodec.kt:152-167 matches rs/moq-lite/src/lite/subscribe.rs::SubscribeResponse::encode Lite-03 arm
SubscribeOk body — 5 fields (priority, ordered, maxLatency, startGroup, endGroup) without id/broadcast/track MoqLiteCodec.kt:127-135
SubscribeDrop body — errorCode varint + reasonPhrase string MoqLiteCodec.kt:137-142
Group uni-stream layout: DataType=0 + size-prefixed (subscribeId, sequence) + size-prefixed frame loop until FIN MoqLiteSession.kt:1021-1050 (publisher), :594-668 (listener drainOneGroup)
Probe body — bitrate u62 varint only (rtt is Lite-04+) MoqLiteCodec.kt:262-266 (encode), :247-251 (decode)
Probe direction — subscriber opens bidi, publisher writes Probe messages MoqLiteSession.kt:925-944 (publisher arm of handleInboundBidi)
Subscribe id allocation — per-session, monotonic, varint MoqLiteSession.kt:83, 246-251 (incremented under state mutex)
Group sequence allocation — per-publisher, monotonic, varint, hot-swap-aware (startSequence parameter) MoqLiteSession.kt:716-746, 1129-1132, 1306-1329
Per-group end = QUIC FIN MoqLiteSession.kt:1264-1271 (endGroup)
Broadcast ended = Announce(Ended) BEFORE FIN (correct ordering) MoqLiteSession.kt:1273-1304 (PublisherStateImpl.close)
Unsubscribe = FIN subscribe bidi's send side (no UNSUBSCRIBE message) MoqLiteSession.kt:670-675
Reliable QUIC streams for groups (NOT bestEffort; matches kixelated) MoqLiteSession.kt:1024-1039 + comment block explaining the prior bestEffort regression
Stream priority hint — newer groups (higher sequence) drain first under congestion MoqLiteSession.kt:1040-1046; sequence-as-priority is monotonically increasing, same direction as kixelated's PriorityHandle.insert(track.priority, sequence)
Auth — purely WebTransport-CONNECT-time (JWT in ?jwt= query); moq-lite layer touches no claims MoqLiteSession.kt has zero references to JWT / claims / auth
Reconnect — no moq-lite-layer message; transport drop ⇒ open new WT session and resubscribe Reconnect lives in connectReconnectingNestsListener / …NestsSpeaker (transport-layer concern)
Goaway recognised + FIN'd cleanly (no body decode today; relay migration not implemented) MoqLiteControlCodes.kt:50-58, MoqLiteSession.kt:973-990
Catalog publisher emits one frame per inbound subscriber via setOnNewSubscriber hook (matches hang.live consumer expectations) MoqLitePublisherHandle.kt:91-114, MoqLiteNestsSpeaker.kt:160-181
hang "legacy" container: each frame is varint(timestamp_us) + raw_opus_packet NestMoqLiteBroadcaster.kt:212-345 (encode), MoqLiteNestsListener.kt:279-285 (decode strip)

The team's 2026-04-26-moq-lite-gap.md ( DONE banner) holds — every phase claim is still accurate against the live source.

Gap matrix

Severity legend (matches the prior QUIC audit):

  • 🔴 High — wire-incompatible with the reference relay; audio doesn't work.
  • 🟡 Medium — works against the reference relay today but violates spec; future relay versions may break.
  • 🟦 Low — diagnostic / observability gap; no functional impact.
# Spec § / Behavior Severity Gap Evidence (file:line) Status
H0 (none — no wire-incompatible items found) 🔴 n/a
M1 Publisher::serve_group priority parity 🟡 We assigned each new group priority = sequence (i32 cast), ignoring track.priority and never re-prioritising in flight. kixelated computes let priority = priority.insert(track.priority, sequence); stream.set_priority(priority.current()) (per rs/moq-lite/src/lite/publisher.rs::serve_group). For our single Opus track at 1 group/sec the difference was unobservable (newer-first ordering still held). MoqLiteSession.kt:openGroupStream (pre-fix) fixed in this audit — see Fix #3
M2 STOP_SENDING for single-group cancel 🟡 The Lite-03 spec lets a receiver cancel a specific group via STOP_SENDING on its uni stream. Pre-fix we closed the consumer-facing frames channel only — the publisher's uni stream stayed open until natural FIN, wasting bandwidth on frames the listener would discard plus relay queue pressure on the per-subscriber forward pipeline. The :quic QuicStream.stopSending(errorCode) API existed but wasn't exposed through WebTransportReadStream. Now plumbed end-to-end: extended WebTransportReadStream with stopSending(code), plumbed through :quic's StrippedWtStream, and MoqLiteSession.drainOneGroup fires stopSending(SUBSCRIPTION_GONE) once on the first frame that observes sub == null. MoqLiteSession.kt:drainOneGroup (pre-fix), WebTransportSession.kt (read interface) fixed in this audit — see Fix #5
M3 RESET_STREAM with Error::to_code() 🟡 Pre-fix all error / cancel paths FIN'd gracefully via runCatching { bidi.finish() }. Lite-03 spec says errors on any stream are conveyed by RESET_STREAM(application_error_code u32). The SubscribeDrop reply paths now write the body and then RESET_STREAM(errorCode) instead of finish(), so a peer watching only the QUIC layer (no body decode) can distinguish typed rejection from graceful "publisher gone" FIN. Plumbing extended WebTransportWriteStream with reset(code) and routed through both QUIC-backed adapters + :quic's StrippedWtStream. MoqLiteSession.kt:handleInboundBidi Drop replies (pre-fix) fixed in this audit — see Fix #5
M4 AnnouncePlease prefix-mismatch falls back to full suffix 🟡 When the relay opened an Announce bidi with prefix="X", our publisher emitted Active(suffix=ourFullSuffix) even when our suffix didn't start with X. The relay would observe an Active update for a broadcast it didn't ask about. In production the relay always asks for prefix="", so this never bit empirically — but it's a spec violation. MoqLiteSession.kt:841-852 (pre-fix) fixed in this audit — see Fix #1
M5 Inbound Subscribe doesn't validate broadcast field 🟡 When the relay opened a Subscribe bidi, we matched on track only, never checking sub.broadcast == publisher.suffix. A relay (or peer) could subscribe to broadcast "otherPubkey" on our connection and we'd happily route OUR audio to them. The production relay routes correctly, so this never bit empirically — but it's a spec violation. MoqLiteSession.kt:861-898 (pre-fix) fixed in this audit — see Fix #2
M6 Goaway body decoding + migration handler 🟡 We recognise ControlType::Goaway = 5 and FIN cleanly. WebFetched the kixelated reference (rs/moq-lite/src/lite/{stream,client}.rs): Goaway has no body schema in moq-lite Lite-03 — it's a single ControlType byte with no payload, not even a migration URL. Our existing handler (recognise, log, FIN) is the canonical implementation; "no body decode" was never a gap. MoqLiteSession.kt:973-990, MoqLiteControlCodes.kt:50-58 closed in this audit — see M6 closure
L1 Lite-04 ALPN constant defined but codec is Lite-03 only 🟦 Pre-fix MoqLiteAlpn.LITE_04 was a documented constant but the codec was hard-wired to Lite-03 — advertising Lite-04 would have caused the very first Announce to desync. Now full Lite-04 support: codec is version-aware via a new MoqLiteVersion enum, all three differing fields (Announce.hops as OriginList, AnnouncePlease.excludeHop, Probe.rtt) are encoded / decoded correctly, ALPN negotiation surfaces the server's pick via WebTransportSession.negotiatedSubProtocol, and connectNestsListener / connectNestsSpeaker advertise both versions and use whichever the relay echoes. MoqLiteCodec.kt, MoqLiteSession.kt, MoqLiteAlpn.kt, QuicWebTransportFactory.kt, WebTransportSession.kt, NestsConnect.kt fixed in this audit — see Fix #8
L2 SubscribeOk always echoes null/null for startGroup/endGroup 🟦 Per spec the publisher MAY narrow the subscriber's requested group bounds. Pre-fix we echoed null/null and lost the diagnostic "which group am I about to start sending?" signal. Now the publisher narrows startGroup to its nextSequence — useful for hot-swap continuations where the seeded startSequence is non-zero. endGroup stays null (live broadcast, no end). MoqLiteSession.kt Subscribe accept arm fixed in this audit — see Fix #6
L3 No subscriber-driven Probe API 🟦 Pre-fix the publisher-side Probe handler existed but no subscriber-side counterpart did, leaving the protocol surface incomplete. New MoqLiteSession.probe() opens a Probe bidi (writes ControlType=4) and returns a MoqLiteProbeHandle whose updates flow yields each MoqLiteProbe the publisher pushes. Mirrors kixelated's Subscriber::run_probe_stream. No production consumer (audio rooms are fixed-rate Opus, no ABR), but the API completes the surface for diagnostic tools. MoqLiteSession.kt probe() (new), MoqLiteHandles.kt MoqLiteProbeHandle (new) fixed in this audit — see Fix #7
L4 Stream-priority overflow guard is a saturating cast 🟦 Pre-fix, setPriority(sequence.coerceAtMost(Int.MAX_VALUE).toInt()) saturated at Int.MAX_VALUE (≈ 71 yrs at 1 grp/sec). After the M1 fix, sequence saturates at the 24-bit boundary (≈ 6 days within a single track) but the per-track high byte keeps cross-track ordering intact. Defensive only; production sessions cycle on JWT refresh every 9 min. MoqLiteSession.kt:openGroupStream (post-M1) closed (subsumed by M1 fix)
L5 Inbound bidi pump captures publishersSnapshot at bidi-arrival time 🟦 If a new publisher was added to the session (session.publish(track="…")) AFTER an inbound bidi opened, the dispatcher couldn't see it. In practice both nests publishers (audio/data and catalog.json) register before any subscriber arrives, so this never bit. Tightened anyway: snapshot is now read at first-byte dispatch time. MoqLiteSession.kt:781-790 (pre-fix) fixed in this audit — see Fix #4

Specifically checked items (per audit-prompt request)

  • Stream priorities + the "stream cliff": the priority assignment drains newer groups first (sequence is monotonically increasing, higher value = higher priority per :quic's QuicStream.priority contract). The stream cliff investigation (2026-05-01-quic-stream- cliff-investigation.md) traced the production cliff to TWO separate root causes, neither of which was a moq-lite priority violation: (a) :quic not emitting MAX_STREAMS_UNI (real bug, fixed in :quic); (b) the production relay's per-subscriber forward-stream-rate ceiling (≈40 streams/sec) being lower than our framesPerGroup=1 push rate of 50 streams/sec. The mitigation (framesPerGroup=50 in production / =5 in interop tests) is a cadence tuning, not a spec violation. Verified: under the current default the listener and speaker both stream cleanly for multi-minute sessions in production logs.
  • Group lifetime / stream retirement: out of scope per audit prompt (:quic is locked). At the moq-lite layer we're confident groups are short-lived (one per framesPerGroup * 20ms = framesPerGroup * 0.02s); each ends in QUIC FIN, and the per-publisher currentGroup reference is dropped on every endGroup so the GC root chain doesn't pin retired streams.
  • Subscribe / Announce flow: verified end-to-end — see MoqLiteSessionTest.subscribe_writes_request_and_returns_handle_on_ok
    • …publisher_acks_subscribe_and_pushes_group_data_on_uni_stream + …publisher_replies_to_announcePlease_with_active_announce. Wire-shape matches the byte-level reference in 2026-04-26-moq-lite-gap.md.
  • Authentication: moq-lite Lite-03 has no auth message inside the WT session — auth is purely the WT-CONNECT JWT in ?jwt= query. Verified: MoqLiteSession.kt has zero auth references; all auth lives in OkHttpNestsClient.mintToken and the WT factory's URL construction.
  • Reconnect: moq-lite has no reconnect message; on transport drop the connectReconnectingNests* wrapper opens a new WT session, mints a new moq-lite session, and re-issues subscribes (ReconnectingNestsListener.kt:317-465). Speaker side hot-swaps the publisher reference inside a long-running broadcaster (NestMoqLiteBroadcaster.swapPublisher), continuing the group sequence across the swap so kixelated/hang's Container.Consumer.#run doesn't drop our post-swap groups.
  • Best-effort vs reliable: verified reliable. The previous bestEffort=true shape was reverted (see comment block at MoqLiteSession.kt:1024-1039); kixelated's reference uses reliable QUIC streams in Publisher::serve_group. Match.

What's deliberately deferred

Nothing. Every gap surfaced by the audit is now resolved. The remaining open items are external follow-ups (not moq-lite gaps):

  • kixelated/moq feature request — file an issue describing the production stream-cliff symptom (relay's per-subscriber forward queue starvation under sustained push) and propose: (a) per-deployment tuning of the unbounded FuturesUnordered task pool, and (b) a deadline on serve_group()'s open_uni().await derived from the active subscriber's smallest max_latency. This is a feature request against the upstream relay, not a defect in our code.

Closed in this audit (no fix needed)

M6 closure — Goaway has no body in moq-lite Lite-03

A second WebFetch pass against https://raw.githubusercontent.com/kixelated/moq/main/rs/moq-lite/src/lite/stream.rs and …/client.rs confirmed: ControlType::Goaway = 5 is a bare discriminator with no payload schema in moq-lite Lite-03. No URL, no preferred-relay address, no error code — it's just the byte. The "no body decode" comment in our existing handler (MoqLiteSession.kt:973-990) was never a gap; there was nothing to decode. The wrapper-layer connectReconnectingNests* already absorbs the eventual hard disconnect, and a Goaway with no body has nothing more to say than "I'm shutting down, expect FIN." Our recognise + log

  • FIN behavior is the canonical implementation.

If a future Lite-04+ revision adds a Goaway body (URL, etc.) we'd need to decode it — that becomes part of the L1 Lite-04 codec work.

Fixes shipped in this audit

Fix #1 — Don't emit Active for non-matching AnnouncePlease prefix (M4)

When the relay opened an Announce bidi with a prefix that didn't match our broadcast suffix, we'd fall through MoqLitePath.stripPrefix's null result and emit Active(suffix=ourFullSuffix, hops=0) anyway, falsely advertising our broadcast under a prefix the subscriber didn't ask for. The reference Rust implementation (rs/moq-lite/src/lite/announce.rs::Producer) only emits Active updates for broadcasts whose path starts with the requested prefix.

Production never hit this because the relay's announce bidi to us always asks for prefix="" — but a future relay version (or a direct peer-to-peer subscriber asking about a specific room) would see ghost broadcasts under their requested prefix.

The fix: when MoqLitePath.stripPrefix(please.prefix, ourSuffix) returns null, FIN the announce bidi without writing any Announce. Subscriber sees a clean end-of-flow.

Regression test: MoqLiteSessionTest.publisher_skips_announce_when_announce_please_prefix_does_not_match.

Fix #2 — Reject inbound Subscribe whose broadcast doesn't match our suffix (M5)

When the relay opened a Subscribe bidi, we matched the requested track against our publisher set but never checked sub.broadcast == ourSuffix. A relay (or peer) could open a Subscribe with broadcast "otherPubkey" on our connection and we'd route OUR audio to them.

Production never hit this because moq-rs's relay routes Subscribe messages to the specific publisher whose broadcast matches the requested path — but a buggy or malicious peer connecting to us directly would have been able to siphon our audio under any broadcast name.

The fix: in the Subscribe arm of handleInboundBidi, check sub.broadcast == publisher.suffix (after path normalisation — both inputs already are, but defensive normalize on the SUBSCRIBE side too in case a peer skipped it). If mismatch, reply SubscribeDrop(errorCode=BROADCAST_DOES_NOT_EXIST, reason="<requested> not published on this session (we publish <ours>)") and FIN.

Regression test: MoqLiteSessionTest.publisher_replies_subscribeDrop_when_broadcast_does_not_match.

Fix #3 — Pack trackPriority + sequence into stream priority (M1)

Publisher::serve_group in kixelated's reference (rs/moq-lite/src/lite/publisher.rs) calls priority.insert(track.priority, sequence) and feeds the resulting position into stream.set_priority. The PriorityHandle orders streams first by track.priority u8 (higher track = drains ahead under congestion), then by group sequence within a track (newer = drains ahead).

Pre-fix we passed raw sequence.toInt() directly to uni.setPriority, ignoring the per-track byte. For our single-Opus- track production case this was unobservable — newer-first ordering held by sequence monotonicity — but a future multi-track broadcast (audio + companion catalog / status track) would have starved the lower-rate track the moment audio's outbound queue got congested.

The fix: add trackPriority: Int = DEFAULT_TRACK_PRIORITY parameter to MoqLiteSession.publish(); store on PublisherStateImpl; bit-pack in openGroupStream as ((trackPriority and 0xFF) shl 24) or (sequence.toInt() and 0x00FF_FFFF).

DEFAULT_TRACK_PRIORITY = 0x80 matches the existing subscriber-side DEFAULT_PRIORITY midpoint, so all existing call sites keep their prior behavior. The 24-bit sequence window is ample (≈ 6 days at 1 grp/sec, beyond which all newer groups within a single track tie — but still beat older groups of any LOWER-priority track via the top byte).

Test seam: FakeWebTransport.openUniStream now records the most- recent setPriority value via a shared AtomicInteger cell that the peer-side FakeReadStream exposes as lastSetPriority. Lets the new regression test verify the bit-pack formula on the actual peer-side read stream rather than peeking into private state.

Regression test: MoqLiteSessionTest.publisher_packs_trackPriority_and_sequence_into_setPriority_value.

Fix #5 — Plumb RESET_STREAM + STOP_SENDING through WebTransport (M2 + M3)

The two items shipped together because the plumbing is shared. After the merge from main brought in the full RFC-compliant :quic layer (commits 9f7f6a9e..6f32975c), the user lifted the ":quic is locked" constraint that originally deferred M2/M3.

Plumbing (new commit `feat(quic,nestsclient): plumb RESET_STREAM

  • STOP_SENDING through WebTransport`):
    • :quic's StrippedWtStream gains optional reset / stopSending closures, wired in WtPeerStreamDemux.emitStripped through QuicStream.resetStream(code) / QuicStream.stopSending(code) + driver.wakeup().
    • WebTransportReadStream.stopSending(code) and WebTransportWriteStream.reset(code) added to the public interface — both suspending, both first-call-wins.
    • All adapters (QuicBidiStreamAdapter, QuicUniWriteStreamAdapter, StrippedWtBidiStreamAdapter, StrippedWtReadStreamAdapter) route directly to the underlying QUIC stream API.
    • FakeWebTransport's FakeBidiStream, FakeReadStream, ChannelWriteStream (now public for test access) record reset / stopSending codes in shared AtomicLong cells with sentinel NO_CODE = Long.MIN_VALUE. New properties lastResetCode, lastStopSendingCode, lastPeerResetCode, lastPeerStopSendingCode, peerStopSendingCode give tests typed-error introspection without mock magic.

M3 (publisher Drop reply uses RESET_STREAM) (fix(nestsclient): RESET_STREAM with typed code after SubscribeDrop body):

  • In handleInboundBidi, both Drop reply paths (BROADCAST_DOES_NOT_EXIST, TRACK_DOES_NOT_EXIST) now write the Drop body and then bidi.reset(errorCode) instead of bidi.finish().
  • The errorCode matches the body's errorCode field, so a peer that decodes the Drop sees the same number as one that only sees the RESET_STREAM frame at the QUIC layer.
  • Strengthened the existing M5 + Drop tests to additionally assert subBidi.lastPeerResetCode == errorCode so the typed-error contract is locked.

M2 (listener stopSending on dead group) (fix(nestsclient): STOP_SENDING on group uni when subscription already canceled):

  • In drainOneGroup, the first frame that observes sub == null (subscription already canceled) fires stream.stopSending(MoqLiteStreamCancelCode.SUBSCRIPTION_GONE) once, latched, so the publisher abandons in-flight retransmits instead of wasting bandwidth.
  • New error-code constant MoqLiteStreamCancelCode.SUBSCRIPTION_GONE = 0x10L lives in MoqLiteMessages.kt next to MoqLiteSubscribeDropCode.
  • Practical impact in production: the relay's per-subscriber forward pipeline (already documented in the stream-cliff investigation) keeps queueing groups for slow / canceled subscribers; an early stopSending at the listener side relieves the relay's queue earlier, complementing the framesPerGroup mitigation.

Regression tests:

  • MoqLiteSessionTest.publisher_replies_subscribeDrop_when_broadcast_does_not_match — additional lastPeerResetCode assertion (M3).
  • MoqLiteSessionTest.publisher_replies_subscribeDrop_when_track_is_not_published — additional lastPeerResetCode assertion (M3).
  • MoqLiteSessionTest.listener_stopSending_group_uni_when_subscription_already_canceled — new test; verifies peerStopSendingCode lands the correct cancel code (M2).

Fix #4 — Refresh publisher list per-dispatch on inbound bidi (L5)

handleInboundBidi previously snapshotted the publisher list at the TOP of the function (= bidi-arrival time) and used that snapshot for the rest of the bidi's lifetime. A publisher registered between bidi-open and the first inbound chunk would miss the dispatcher's view, even though the publisher was already live in activePublishers by the time we needed to dispatch.

Practical impact today: zero — both nests publishers register from MoqLiteNestsSpeaker.startBroadcasting before the relay's SUBSCRIBE bidi for either track lands. The ~few-ms gap is below the network round-trip floor.

But the contract is narrower if we read the list at first-byte time: we then see every publisher that was registered up to the moment we needed to make a routing decision. Move the publishers fetch from the function-top to inside the if (!dispatched) block, after the control-type byte has been read. On empty publisher list (the case we previously short-circuited at function-top), we now FIN cleanly so the peer's wait resolves instead of hanging on an idle bidi.

No new regression test — exercised end-to-end by the existing publisher tests, which now pass against the freshness-aware dispatcher.

Fix #6 — SubscribeOk narrows startGroup to publisher.nextSequence (L2)

When accepting a SUBSCRIBE, the publisher MAY narrow the subscriber's startGroup / endGroup request bounds per spec. Pre-fix we always echoed null/null, which lost the diagnostic "which group am I about to start sending?" information — particularly useful for hot-swap continuations where [MoqLitePublisherHandle.nextSequence] is non-zero (the seeded startSequence from the previous moq-lite session).

The fix narrows startGroup to targetPublisher.nextSequence. The subscriber decodes this as "the next group on this subscription will be sequence N" and can log / surface the join-point. endGroup stays null because live audio rooms have no end in sight; the subscriber's request bound is honoured implicitly when the publisher closes.

Regression test: MoqLiteSessionTest.publisher_subscribeOk_narrows_startGroup_to_next_sequence.

Fix #7 — Subscriber-driven Probe API (L3)

MoqLiteSession.probe() mirrors kixelated's Subscriber::run_probe_stream (rs/moq-lite/src/lite/subscriber.rs): opens a bidi, writes ControlType::Probe (varint 4), and returns a MoqLiteProbeHandle whose updates flow yields each size-prefixed MoqLiteProbe message the publisher pushes. The handle's close() FINs the bidi and cancels the pump.

updates is a MutableSharedFlow(replay=8) so a collector that attaches after the publisher's first emit doesn't miss it (matches the same shape the announce-watch uses).

No production consumer for the API today — Amethyst's nests listener doesn't run ABR on a fixed-rate Opus encoder. The API exists to round out the moq-lite Lite-03/04 surface: a diagnostic tool can now read a publisher's bitrate without subscribing to its data track.

Regression test: MoqLiteSessionTest.subscriber_probe_writes_control_type_and_decodes_publisher_bitrate.

Fix #8 — Version-aware Lite-03/04 codec + ALPN negotiation (L1)

Pre-fix the codec was hard-wired to Lite-03; advertising the documented MoqLiteAlpn.LITE_04 constant would have caused the very first Announce to desync because Lite-04 reshapes three fields. Now full Lite-04 support, gated on a runtime version discriminator selected by ALPN negotiation.

Wire diff verified via WebFetch against kixelated/moq main (2026-05-09) at rs/moq-lite/src/lite/{announce,subscribe,probe}.rs and rs/moq-lite/src/model/origin.rs:

  • Announce.hops: Lite-03 = single varint count (the spec fills with Origin::UNKNOWN placeholders on decode); Lite-04 = varint(count) + count × varint(originId) (the OriginList). MAX_HOPS = 32.
  • AnnouncePlease.excludeHop: Lite-03 absent; Lite-04 single varint after prefix. Sentinel 0 = no exclusion.
  • Probe.rtt: Lite-03 absent; Lite-04 single varint after bitrate. Sentinel 0 = unknown (decoded as null). Outgoing Some(0) clamped to Some(1) to avoid colliding with the sentinel — mirrors kixelated's encode_msg clamp.

Surface added:

  • MoqLiteVersion enum (LITE_03, LITE_04) with fromAlpn.
  • All affected MoqLiteCodec methods take version: MoqLiteVersion = LITE_03.
  • MoqLiteSession.client(transport, scope, version) carries the version through every codec invocation.
  • Data classes evolved: MoqLiteAnnouncePlease.excludeHop (default 0L), MoqLiteAnnounce.hops (LongList<Long>), MoqLiteProbe.rtt (Long?, default null).
  • WebTransportSession.negotiatedSubProtocol: String? exposes the server's wt-protocol selection. QuicWebTransportFactory parses the response HEADERS, extracts the SF-string from wt-protocol, and threads it into QuicWebTransportSession. FakeWebTransport.pair(negotiatedSubProtocol = …) lets tests drive the same path.
  • Default advertised list now [moq-lite-04, moq-lite-03]. Lite-04 first to match kixelated's preference; servers that don't support it fall back to Lite-03 cleanly.
  • connectNestsListener / connectNestsSpeaker resolve the version via resolveMoqLiteVersion(webTransport.negotiatedSubProtocol) and pass it to MoqLiteSession.client(...). Falls back to Lite-03 when the server doesn't echo wt-protocol (older deployments) or echoes an unrecognised value (forward-compat).

Regression tests (all green):

  • MoqLiteCodecTest.announcePlease_lite04_round_trips_excludeHop
  • MoqLiteCodecTest.announcePlease_lite03_omits_excludeHop_on_wire
  • MoqLiteCodecTest.announce_lite04_round_trips_full_origin_list
  • MoqLiteCodecTest.announce_lite03_drops_origin_ids_keeps_count
  • MoqLiteCodecTest.announce_decoder_rejects_oversize_hop_count
  • MoqLiteCodecTest.probe_lite04_round_trips_rtt
  • MoqLiteCodecTest.probe_lite04_clamps_some_zero_to_one_to_avoid_unknown_sentinel
  • MoqLiteCodecTest.probe_lite04_decodes_zero_rtt_as_null
  • MoqLiteCodecTest.probe_lite03_wire_omits_rtt
  • MoqLiteSessionTest.lite04_announce_round_trips_full_origin_list_through_session

Build / test verification

Baseline :nestsClient:jvmTest was green at audit start (140 tests). After both fixes + regression tests, still green. Default-mode tests run in seconds; the -DnestsInterop=true Docker suite is unchanged by these fixes (they only tighten our existing publisher-side behaviour against malformed input the production relay never sends).

Pointers

  • Wire spec + IETF gap (DONE banner): 2026-04-26-moq-lite-gap.md.
  • Stream cliff investigation (production-fixed): 2026-05-01-quic-stream-cliff-investigation.md.
  • Frames-per-group reconciliation (production vs interop): 2026-05-07-framespergroup-reconciliation.md.
  • Audio-rooms completion plan: 2026-04-26-audio-rooms-completion.md (will reference this audit in its compliance section).
  • Reference Rust impl: https://github.com/kixelated/moq/tree/main/rs/moq-lite/src/.