Files
amethyst/marmotQuic
Claude 6d43d1941a feat(marmot): agent text stream previews over the repo's own QUIC stack
The transport binding was the last piece missing from agent text streams,
and it did not need a new QUIC implementation — `:quic` already had the
whole hard part. What it needed was entering that stack at the right
layer.

`nestsClient` speaks WebTransport: HTTP/3, Extended CONNECT, QPACK,
SETTINGS. Its `WebTransportSession` abstraction begins above all of that.
Marmot's binding is raw QUIC — it negotiates its own ALPN
(`marmot.quic_broker.v1` / `marmot.quic_stream.v1`) and writes frames
straight onto QUIC streams, with no HTTP/3 anywhere in it. So this reuses
everything below that line — connection, TLS 1.3, ALPN negotiation,
stream multiplexing, loss recovery, the UDP socket — and none of the
WebTransport wrapper.

The codecs are in quartz next to the rest of agent-text-stream, because
they are pure bytes and that is where the conformance risk lives: the
control envelope with its literal 21-byte protocol string and its
trailing-byte rejection, the uint32 frame codec with both the broker's
blind cap and a policy-aware one, `quic://` candidate parsing down to
ignoring everything after the authority and never sending an IP literal
as SNI, and the first record's stream id pinning the rest.

`:marmotQuic` is the connection layer, mirroring how `:nestsClient` sits
on `:quic`. A publisher claims a room on a uni stream, a subscriber reads
the fan-out on a bidi one, and an endpoint that does not take our ALPN is
reported as unusable so the caller moves to the next candidate rather
than waiting on records that never come.

Verified against MDK's own `marmot-quic-broker`, which is the only way to
know a wire format is right: our publisher and subscriber meet inside the
reference broker, the records come back, open under the group-derived key
and fold to the publisher's transcript hash, and the broker keeps rooms
apart. Opt in with -DmarmotQuicBroker=host:port; the cases skip visibly
without one, so an ordinary test run needs no broker.

Still not wired at the app layer: nothing yet mints a kind-1200 start,
picks a candidate, or renders a live preview, so `send` (0xF2D2) and
`fanout` (0xF2D4) stay unadvertised. The direct path has no start-payload
candidate format in v1 and is unimplemented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 11:29:21 +00:00
..

marmotQuic

Marmot's raw QUIC transport binding for agent text stream previews (transports/quic.md), on top of the repo's own pure-Kotlin :quic stack.

Why this is not nestsClient's WebTransport

Both features move bytes over :quic, but they enter it at different layers.

nestsClient speaks WebTransport: HTTP/3, an Extended CONNECT handshake, a :protocol pseudo-header, QPACK, SETTINGS negotiation. Its WebTransportSession abstraction starts above all of that.

Marmot's binding is raw QUIC. It negotiates its own ALPN — marmot.quic_broker.v1 for the broker path, marmot.quic_stream.v1 for the direct one — and writes frames straight onto QUIC streams. There is no HTTP/3 in it at all, so WebTransportSession is the wrong shape.

What both share is everything below that line, which is the hard part and is already built: the QUIC connection, TLS 1.3, ALPN negotiation, stream multiplexing, loss recovery and the UDP socket.

Shape

  • A publisher opens a client-initiated unidirectional stream, writes a publish control envelope, then record frames.
  • A subscriber opens a client-initiated bidirectional stream, writes a subscribe control envelope, and reads the fan-out on the return direction.

A broker rejects the wrong pairing. Both roles frame everything the same way: uint32 frame_len || bytes, the control envelope first and then each AgentTextStreamRecordV1.

The codecs — control envelope, frame reader/writer with both caps, quic:// candidate parsing — live in quartz next to the rest of agent-text-stream, because they are pure bytes and belong with the feature. This module is only the connection.

The broker sees nothing

Records are encrypted under a key derived from the group's MLS exporter. A broker holds no key and learns only the routing pair (stream_id, start_event_id) plus ciphertext. It is an untrusted forwarder, and a candidate that points somewhere hostile still cannot forge a record.

Interop tests

MarmotQuicBrokerInteropTest drives our publisher and subscriber through MDK's own reference broker. Start it from an MDK checkout:

cargo build --release --bin marmot-quic-broker
./target/release/marmot-quic-broker --bind 127.0.0.1:4450 --json

then:

./gradlew :marmotQuic:jvmTest -DmarmotQuicBroker=127.0.0.1:4450

Without the property the cases skip visibly, so an ordinary ./gradlew test never needs a broker on the machine.

Not done

  • Nothing in the app yet mints a kind-1200 start payload, chooses a broker candidate, or renders a live preview — this is the transport, not the feature wiring.
  • The direct path (marmot.quic_stream.v1) is unimplemented. v1 defines no start-payload candidate format for it, so it is only reachable with an endpoint known out of band.