docs(audio-rooms): full moq-lite wire spec + IETF gap call-outs

Background research turned up the complete moq-lite (Lite-03) wire
format from kixelated/moq-rs and @moq/lite v0.1.7. Folded the spec
into nestsClient/plans/2026-04-26-moq-lite-gap.md as a phase-5
implementation plan:

  - ALPN ("moq-lite-03"); no SETUP/control message in Lite-03 (the WT
    handshake IS the handshake)
  - Per-request bidi streams keyed by ControlType varint (Announce=1,
    Subscribe=2, Fetch=3, Probe=4)
  - AnnouncePlease(prefix) / Announce(status, suffix, hops) shape
  - Subscribe with priority (raw u8), ordered, maxLatency (ms),
    startGroup/endGroup (off-by-one None-encoded), reply Ok/Drop
  - Group = uni stream with (DataType=0, subscribeId, sequence)
    header followed by varint-length frames until QUIC FIN
  - No datagrams; no per-frame envelope beyond size
  - Mandatory path normalisation; FIN-as-unsubscribe; RESET_STREAM
    for errors

Phase-5a..e implementation plan included (~1 week scope).

Doc + KDoc updates so the IETF MoQ-transport code is correctly
labelled and the moq-lite gap is discoverable from every entry point:

  - .claude/CLAUDE.md project description and architecture diagram
  - nestsClient/plans/2026-04-26-audio-rooms-completion.md status block
  - MoqSession.kt, MoqMessage.kt, MoqObject.kt, MoqCodec.kt KDoc — flag
    these as "IETF draft-ietf-moq-transport-17, NOT moq-lite", with
    pointers to the gap doc
  - NestsConnect.kt — note that step 3 of the listener handshake
    (SETUP) does NOT match nostrnests's relay framing
This commit is contained in:
Claude
2026-04-26 16:39:44 +00:00
parent 1887bd1fa7
commit 7f48e52541
8 changed files with 305 additions and 95 deletions
+240 -74
View File
@@ -1,30 +1,186 @@
# Plan: bridge the moq-lite protocol gap
**Status:** discovered, not yet decided.
**Context:** discovered while writing the nostrnests interop test suite (phases 14).
**Status:** wire spec known; implementation pending.
**Origin:** discovered while writing the nostrnests interop test suite (phases 14).
## Discovery
`:nestsClient` implements **IETF draft-ietf-moq-transport-17** (`TrackNamespace`
tuples, `CLIENT_SETUP` / `SERVER_SETUP`, `OBJECT_DATAGRAM` with `track_alias`,
two-message ANNOUNCE / SUBSCRIBE shape, etc.).
`:nestsClient` implements **IETF `draft-ietf-moq-transport-17`**
(`TrackNamespace` tuples, `CLIENT_SETUP` / `SERVER_SETUP`,
`OBJECT_DATAGRAM` with `track_alias`, two-message ANNOUNCE / SUBSCRIBE
shape).
The actual nostrnests stack is built on **moq-lite** — kixelated's own MoQ
flavour, wire-incompatible with IETF MoQ-transport. Reference points:
The actual nostrnests stack runs on **moq-lite** — kixelated's own MoQ
flavour, wire-incompatible with IETF MoQ-transport:
- JS client `NestsUI-v2/package.json` depends on `@moq/lite`, `@moq/publish`,
`@moq/watch`. None are IETF MoQ-transport.
- Rust relay `kixelated/moq-rs` is built on `rs/moq-lite/` types throughout
(see `rs/moq-lite/src/path.rs`, `rs/moq-lite/src/model/origin.rs`).
- ANNOUNCE wire on moq-lite: `(active: bool, suffix: string)` — single
string, no tuple. Source: `@moq/lite/lite/announce.js:14-19`.
- SUBSCRIBE wire on moq-lite:
`(id: u62, broadcast: string, track: string, priority: u8, …)` — two
independent strings. Source: `@moq/lite/lite/subscribe.js:87-91`.
- Path model: a plain string, `/`-joined and trim-normalised; prefix-strip
is delimiter-aware (`"foo"` does NOT match `"foobar"`).
- JS client `NestsUI-v2/package.json` depends on `@moq/lite`,
`@moq/publish`, `@moq/watch`. None implement IETF MoQ-transport.
- Rust relay `kixelated/moq-rs` is built on `rs/moq-lite/` types
throughout.
Concrete shapes the JS reference sends per nests room:
The phase-4 wire fixes (path = `/<namespace>`, JWT in `?jwt=` query) are
on the moq-rs wire shape and let the WebTransport CONNECT succeed. But
the first MoQ-framing message we send afterwards (IETF `ClientSetup`)
is unintelligible to moq-rs's moq-lite framing.
## Wire spec — moq-lite (Lite-03)
Fully extracted from `kixelated/moq-rs/rs/moq-lite/src/` and
`@moq/lite` v0.1.7.
### Connection setup
- **ALPN advertised on the WebTransport upgrade:** prefer
`"moq-lite-03"` (`Lite.ALPN_03`); fall back to `"moql"` (legacy
combined ALPN that requires a SETUP exchange).
Source: `rs/moq-lite/src/version.rs:21-26`,
`@moq/lite/connection/connect.js:277`.
- **Lite-03 has NO setup or control message inside the WT session.**
The WebTransport handshake itself is the handshake. Both sides go
straight to opening per-purpose streams. `client.rs:86-101`,
`connect.js:113-115`.
- Legacy Lite-01/02 (`"moql"`) DO open a bidi setup stream with byte
`0x20``ClientSetup` (Draft-14 IETF-format), reply `0x21`
`ServerSetup`, then exchange `SessionInfo {bitrate: u62}` forever.
We are targeting Lite-03 only.
- Version is **chosen entirely by ALPN.** No version, role, or setup
parameter is exchanged inside the WT session.
### Streams + datagrams
- **No persistent control stream.** Each control "request" is a fresh
client-initiated **bidi**, whose first byte is a varint
`ControlType` discriminator and whose body is a size-prefixed message.
The bidi stays open for the duration of the request/response/stream.
- `ControlType` codes (`lite/stream.rs:7-15`): `Session=0` (unused in
Lite-03), `Announce=1`, `Subscribe=2`, `Fetch=3`, `Probe=4`. All
encoded as QUIC varints.
- **Media flows on uni streams**, one stream per *group*. Uni-stream
type byte: `DataType::Group = 0` (varint, in practice a literal
`0` byte). `lite/stream.rs:32-36`, `publisher.js:209`.
- **No QUIC datagrams used for media in Lite-03.** Each group is its
own uni stream; no datagram path.
### Announce
Two-step, on a single bidi opened by the **subscriber**:
1. **Subscriber → publisher** (or relay). Bidi opens with control byte
`varint(1)` = `Announce`, then size-prefixed `AnnouncePlease`:
prefix: string (varint length + UTF-8; broadcast-name prefix
the subscriber cares about; empty = "everything")
Source: `subscriber.rs:82-89`, `subscriber.js:46-47`,
`announce.rs:64-81`.
2. **Publisher → subscriber.** Server emits one size-prefixed
`Announce` message per active broadcast, then keeps the bidi open
for live updates:
status: u8 (0 = Ended, 1 = Active)
suffix: string (broadcast path with `prefix` stripped;
normalised — `path.rs:78-99`)
hops: u62 (Lite-03 only; varint, relay routing depth)
Source: `announce.rs:84-90`, `announce.rs:28-31`. `active=true` is
sent on first publish; `active=false` on explicit unannounce.
Disconnect is **not** an explicit Ended (see Cleanup).
### Subscribe
Subscriber opens a **fresh bidi**, control byte `varint(2)` = `Subscribe`,
then size-prefixed body:
id u62 varint (subscriber-chosen, monotonic)
broadcast string (varint length + UTF-8 path)
track string (opaque app string —
"audio/data" or "catalog.json")
priority u8 (raw byte 0..255; NOT a varint)
ordered u8 (Lite-03 only; 0 / 1)
maxLatency varint (Lite-03; **milliseconds**, 0 = unlimited)
startGroup varint (Lite-03; 0 = "from latest",
else group_seq + 1)
endGroup varint (Lite-03; 0 = "no end",
else group_seq + 1)
Source: `subscribe.rs:25-72`, `subscribe.js:87-104`,
`encode.rs:99-185`.
Reply: size-prefixed `SubscribeResponse` on the same bidi. Lite-03
prefixes a varint type: `0 = Ok`, `1 = Drop`. `SubscribeOk` body is
`(priority, ordered, maxLatency, startGroup, endGroup)` — same
five fields as Subscribe minus id/broadcast/track.
There is **no SUBSCRIBE_ERROR**. Failure = stream RESET with an
`Error::to_code()` u32. Track names are arbitrary opaque UTF-8.
### Frame / group / object delivery
For each group, the publisher opens a fresh uni stream:
DataType (varint) = 0 (Group)
Group header (size-prefixed):
subscribe u62 varint (echoes Subscribe.id)
sequence u62 varint (group sequence number)
then a sequence of frames until the stream's FIN:
frame_size varint (length in bytes)
payload frame_size raw bytes
Source: `publisher.rs:330-393`, `publisher.js:205-232`,
`subscriber.js:152-161`.
**No per-frame envelope beyond size.** No timestamp, no codec config,
no flags. All semantic structure (Opus packet boundaries, JSON
document) is the track's app-layer convention. End-of-group is QUIC
stream FIN.
### Cleanup / unsubscribe / unannounce
- Per-group end / track ended: QUIC **FIN** the uni stream
(`publisher.rs:387-388`).
- Broadcast ended: send `Announce {status=0=Ended, suffix, hops}` on
the announce bidi (`publisher.rs:206`, `publisher.js:118`).
- Unsubscribe: **FIN the subscribe bidi's send side**
(`subscriber.rs:230`). No UNSUBSCRIBE message exists.
- Cancel a single group from the receiver: QUIC `STOP_SENDING` on the
uni stream.
- Mid-broadcast publisher disconnect: relay either FINs/resets the
announce bidi or emits `Announce::Ended` if graceful. Consumers
detect via the bidi/QUIC close — there is no "publisher gone"
message.
- Errors on any stream: `RESET_STREAM` with `Error::to_code()` (u32).
### Varint encoding
**RFC 9000 §16 QUIC varints** (2-bit length tag, 1/2/4/8 byte forms,
max value `2^62 1`). `coding/varint.rs:172-239`,
`stream.js:147-170`. The `stream.js` `u53` reader caps at the JS-safe
range. `priority` is a plain byte. Strings = `varint length + UTF-8`.
`bool` = 1 byte 0/1.
### Notable + non-obvious
- **Same WT session is bidirectional w.r.t. roles.** Either side can
open an Announce or Subscribe bidi; the role is per-bidi via the
`ControlType` byte. There is no role announcement.
- **`Path` normalisation is mandatory** on both sides — leading,
trailing, and duplicate `/` are stripped before encoding. A wire
path `"/foo//bar/"` is identical to `"foo/bar"`. An interop client
that doesn't normalise will see broadcast lookups silently fail.
- **`startGroup`/`endGroup` use the off-by-one trick:**
`0 = None, n = Some(n 1)`. Easy to get wrong.
- **`maxLatency` is in milliseconds.** Not seconds, not microseconds.
- **No head-of-line blocking across groups** (one uni stream per
group), but no in-order guarantee across them either. The receiver
uses `sequence` to reorder.
- **`hops`** is the only relay-routing metadata on the wire (Lite-03).
- **Probe stream (ControlType=4)** is opened by the *subscriber* but
the publisher writes `Probe { bitrate: u62 }` size-prefixed messages
on it — opposite direction from a normal request/response.
## Concrete wire shapes nests-side
| Wire field | JS reference value |
| ---------------------- | ---------------------------------------- |
@@ -36,75 +192,85 @@ Concrete shapes the JS reference sends per nests room:
| SUBSCRIBE.broadcast | `<speakerPubkey>` (single string) |
| SUBSCRIBE.track | `"catalog.json"` then `"audio/data"` |
## Why this matters
## Implementation plan
The wire-shape fixes landed in this PR (path = `/<namespace>`, JWT in
`?jwt=` query) make the WebTransport CONNECT itself succeed against
moq-rs. But the FIRST MoQ control message we send afterwards
(IETF `ClientSetup`) is unintelligible to moq-rs's moq-lite framing, so
all post-CONNECT integration tests (round-trip, multi-peer, fan-out,
subscribe-before-announce) cannot pass against real nests in their
current form.
### Phase 5a — codec primitives (1 day)
## Options
- `MoqLiteVarint` — RFC 9000 varint reader/writer (we already have
`Varint` in `:quic`; reuse).
- `MoqLitePath` — string with `normalize()` (strip leading/trailing/
duplicate `/`) + `join(prefix, suffix)`.
- `MoqLiteWriter` / `MoqLiteReader` — varint, length-prefixed string,
size-prefixed message envelope.
### A. Add moq-lite codec alongside the IETF one
### Phase 5b — message codec (12 days)
- New `MoqLiteSession` parallel to `MoqSession`.
- New `NestsListener` / `NestsSpeaker` impls switched at construction
time, or behind a `MoqDialect` enum on `NestsRoomConfig`.
- ~12 weeks of work; two parallel protocols to maintain.
- Keeps the IETF unit-test suite intact for any future IETF target.
- **Pro:** real interop with nests; production speaker/listener works.
- **Con:** dual codepaths.
- `MoqLiteAnnouncePlease(prefix: String)`
- `MoqLiteAnnounce(status: AnnounceStatus, suffix: String, hops: Long)`
- `MoqLiteSubscribe(id, broadcast, track, priority, ordered,
maxLatency, startGroup, endGroup)`
- `MoqLiteSubscribeOk(priority, ordered, maxLatency, startGroup,
endGroup)`
- `MoqLiteSubscribeDrop(...)` (decode-only)
- `MoqLiteGroupHeader(subscribeId, sequence)`
- `MoqLiteFrame(payload)`
- Round-trip tests against hand-rolled byte sequences — exactly the
pattern `MoqCodecTest` uses today.
### B. Drop IETF MoQ-transport, replace with moq-lite
### Phase 5c — session layer (35 days)
- Rewrites `MoqSession`, `MoqCodec`, `MoqMessage`, all unit tests.
- Smaller surface long-term.
- **Pro:** one truth; less code.
- **Con:** discards finished IETF work; risks rework if the audio-rooms
NIP later pivots to IETF MoQ.
- `MoqLiteSession` parallel to `MoqSession`:
- `client(transport, scope)` — no SETUP step; just spawn pumps.
- `announce(suffix)` — subscriber-side: opens bidi with
ControlType=Announce + AnnouncePlease(prefix=""), returns a flow
of incoming `Announce` updates.
- `publish(suffix)` — publisher-side: opens an uni-stream-per-group
pump under our broadcast.
- `subscribe(broadcast, track)` — opens bidi with
ControlType=Subscribe + body, awaits `SubscribeOk`, returns a
`MoqLiteSubscribeHandle` whose `frames` flow yields each frame
grouped by sequence.
- `close()` — close all streams.
- Path normalisation applied automatically at every wire boundary.
- ALPN wired through `:quic`'s WT factory: `"moq-lite-03"`.
### C. Hold integration round-trip / multi-peer tests, ship now
### Phase 5d — production wiring (1 day)
- This PR: phase-1 auth ping, phase-3 round-trip code, phase-4 wire fix,
phase-4 negative auth + endpoints, phase-4 multi-peer code.
- All test code that reaches into MoQ framing is `-DnestsInterop=true`
gated, so the default test run stays green.
- Land a TODO in this doc; pick A or B as a separate planned phase.
- Replace `MoqSession.client(...)` calls in `connectNestsListener` /
`connectNestsSpeaker` with `MoqLiteSession.client(...)`.
- `subscribeSpeaker(pubkey)` becomes:
- subscribe `(broadcast=pubkey, track="catalog.json")` — discover
metadata
- subscribe `(broadcast=pubkey, track="audio/data")` — receive
Opus frames
- Speaker side: announce `(suffix=ourPubkey)`, publish `audio/data`
frames as one group per session (or rotate groups periodically).
- Drop the `TrackNamespace` plumbing on the nests side — it doesn't
apply to moq-lite.
### D. Pivot the round-trip target to an IETF MoQ-transport server
### Phase 5e — integration tests (existing)
- e.g. quic-go-moq, aioquic-moq, moq-go.
- Validates our IETF MoQ codec but does NOT validate nests interop —
different goalpost, same effort to wire up a Docker harness.
- The `-DnestsInterop=true` round-trip + multi-peer tests should now
pass against the real Docker'd moq-rs. No test rewrites needed; the
tests drive `connectNestsSpeaker` / `connectNestsListener` and
`listener.subscribeSpeaker(pubkey)` — purely through the public API.
## Recommendation
## Decision points still open
**C now, A next phase.** Land the wire fixes + HTTP-only tests + the
multi-peer test code (gated) so we have the test scaffolding in place
when moq-lite framing lands. Treat moq-lite as an explicit phase-5
work item with its own design doc.
## Open questions
- Does moq-rs accept IETF MoQ-transport behind any flag? (Quick check
needed; agent's read of `rs/moq-lite/` suggests no.)
- Is the audio-rooms NIP draft IETF-MoQ-transport-binding, or
moq-lite-binding? If IETF, nostrnests is non-conforming and may
switch; if moq-lite, the NIP itself is moq-lite-bound and we should
pursue option A.
- ALPN check: WT itself uses `h3`; moq-lite framing rides on top of
WT bidi streams + datagrams, same as IETF MoQ-transport. The
`/anon` vs `/<namespace>` URL path mismatch is unrelated.
- **Keep IETF MoQ-transport code (option A) vs delete (option B)?**
Recommend **A** — keeps `MoqSession` reachable for any future IETF
target, and the unit-test suite is genuinely useful as a reference
implementation. The IETF code is ~1.5k LOC of well-tested codec
that costs ~nothing to keep around.
- **Where does `MoqLiteSession` live?** Same package
(`com.vitorpamplona.nestsclient.moq`) under a `lite/` subpackage —
matches the upstream `rs/moq-lite/` layout.
## When picking up
- Read `NestsUI-v2/src/transport/moq-transport.ts` and the `@moq/lite`
/ `@moq/publish` / `@moq/watch` packages cached at
`~/.cache/amethyst-nests-interop/nests/NestsUI-v2/node_modules/`.
- Read `kixelated/moq-rs/rs/moq-lite/src/` (announce.rs, subscribe.rs,
path.rs, model/origin.rs) for the relay's view.
- Read `~/.cache/amethyst-nests-interop/nests/NestsUI-v2/node_modules/@moq/lite/`
for the JS reference.
- Read `kixelated/moq-rs/rs/moq-lite/src/{lite,coding,client,version,
path}.rs` for the canonical Rust implementation.
- The nests-side `claims.put = [pubkey]` rule is in
`moq-auth/src/index.ts:160-166`.