Files
amethyst/marmotQuic/README.md
T
Claude 1794ed5249 fix: make the sync benchmark opt-in; stop advertising the QUIC preview path
Two unrelated things that both amount to not paying for something nobody
asked for.

**`MirrorSyncThroughputTest` is a benchmark, so it now opts in.** It
preloaded a million events and pulled them over a real WebSocket on every
ordinary test run: 4,584 s of `:geode:test`'s 4,636 s — 98.9% of the
module's test time for one test that asserts nothing about correctness and
reported `skipped` at the end anyway. Every other benchmark in the module
is already gated this way (`perf.LoadBenchmark`). It now bails before
building anything, and enables on `-DrunLoadBenchmark=true` OR on any of
its own sizing properties, so every invocation its kdoc documents still
runs it — naming a size is itself the opt-in. Measured after: the test
takes 5 ms, the module takes 64.8 s, and `-DsyncN=2000` still prints a
throughput number.

**The agent text stream QUIC path is kept but no longer advertised, and
nothing starts it.** Nothing in the deployed network publishes those
previews. So:

- `SUPPORTED_COMPONENTS` drops `0x8006` and the leaf capabilities drop
  `0xF2D1`/`0xF2D2`/`0xF2D4`. A capability is a standing promise to every
  peer that reads our KeyPackage, and one for a path nobody exercises
  costs something and buys nothing. The captured reference KeyPackage in
  our own conformance vector does not advertise `0x8006` either.
- The Android chat screen no longer builds a stream watcher and dials the
  brokers a kind:1200 advertises. That was a UDP connection attempt to a
  third-party endpoint on every feed change, on behalf of a feature with
  nothing to show — a service we start, not a capability we hold.

The implementation stays and stays tested: `:marmotQuic`, the codecs,
`amy marmot stream`, the direct path, the certificate pinning and the
interop tests are all untouched. The module README records the posture and
the exact way back.

Three tests asserted the old advertisement and were reworked rather than
deleted. The role-enforcement gate is still covered — the tests now build
leaves that explicitly carry the roles, which is the better shape anyway,
since a test that exercised the gate through OUR default was really
asserting the default and stopped testing the gate the moment it changed.
A new test pins the new default: our KeyPackage carries no role and is
therefore refused by a group requiring one. That refusal is the deliberate
cost, so it is asserted rather than discovered.

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

6.2 KiB

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 direct sender opens a client-initiated unidirectional stream and writes record frames with no control envelope — the dialed endpoint is already the one receiver, so there is no room to name.

A broker rejects the wrong pairing. Every role frames the same way: uint32 frame_len || bytes — on the broker path the control envelope first and then each AgentTextStreamRecordV1, on the direct path records from the first byte.

Note the direct path's connection direction: the receiver listens and the sender dials, inverted from the broker path where both ends dial the broker. Only the sender half is here; :quic is a client stack with no server role, so this module cannot expose a direct-path endpoint of its own.

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.

TLS trust

Preview endpoints and brokers are commonly self-signed, and the binding says so: a client MAY pin the endpoint certificate by exact DER or SHA-256 fingerprint instead of chaining to a CA. PinnedCertificateValidator (in :quic) is that pin. It replaces the chain and the hostname check and nothing else — the peer still has to sign the TLS transcript with the pinned certificate's private key, so copying a public certificate off the wire buys an attacker nothing.

amy marmot stream send|watch takes --pin-sha256 HEX[,HEX…]; the reference broker prints its own server_cert_sha256_fingerprint in its startup JSON.

Interop tests

MarmotQuicBrokerInteropTest drives our publisher and subscriber through MDK's own reference broker, and MarmotQuicDirectInteropTest drives our direct sender against MDK's direct receiver (wn stream receive). Both are the only way to know the binding is right: an ALPN string, a stream direction, a missing control envelope and a frame prefix are all things an implementation will happily agree with itself about.

Start the broker from an MDK checkout:

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

then:

./gradlew :marmotQuic:jvmTest \
  -DmarmotQuicBroker=127.0.0.1:4450 \
  -DmarmotQuicBrokerPin=<server_cert_sha256_fingerprint from that JSON> \
  -DmarmotWn=/path/to/mdk/target/release/wn

Each property gates its own cases and they skip visibly without it, so an ordinary ./gradlew test never needs the reference implementation on the machine. -DmarmotWn needs no running process: the test spawns wn stream receive itself on a free port.

Using it

amy marmot stream drives the whole feature; the harness's tests 18 and 19 run it in both directions against MDK.

amy marmot stream start GID --broker quic://127.0.0.1:4450
amy marmot stream send  GID --stream-id … --start-event-id … --broker … "hello"
amy marmot stream send  GID --stream-id … --start-event-id … --direct quic://host:port "hello"
amy marmot stream watch GID --stream-id …
amy marmot stream finish GID --stream-id … --transcript-hash … --chunk-count N "hello"

Not wired into the app

The implementation is complete and tested, and nothing in the app starts it.

Nothing in the deployed network publishes agent text stream previews, so the Android chat screen no longer builds a watcher and dials the brokers a kind:1200 advertises, and our published KeyPackage no longer advertises component 0x8006 or the receive/send/fanout role capabilities. A capability is a standing promise to every peer that reads the KeyPackage; making one for a path nobody exercises costs something and buys nothing.

What that leaves: the codecs, this module, the CLI (amy marmot stream …) and the interop tests all still work and still run. Turning the feature back on is re-adding AppComponentIds.AGENT_TEXT_STREAM_QUIC_V1 to CurrentProfileGroupFactory.SUPPORTED_COMPONENTS, the three roles to MlsGroup.currentProfileLeafCapabilities(), and the watcher to MarmotGroupChatView.

Not done

  • The Android GUI renders previews but does not originate a stream — that is an agent's job, and no agent runs in the app yet. Only amy publishes one.
  • The desktop app has no Marmot chat screen at all, so there is nothing to render a preview into. The watcher it would use already lives in commons.
  • The direct path's receiving half. :quic has no server role, so this module can dial a direct receiver but cannot be one. v1 also defines no start-payload candidate format for the direct path, so a sender only reaches a receiver whose endpoint it already knows out of band.