diff --git a/quest/m1/README.md b/quest/m1/README.md
index 09b3bdd90c..d11e511ab7 100644
--- a/quest/m1/README.md
+++ b/quest/m1/README.md
@@ -23,23 +23,30 @@ transport, benchmark tooling); worktrees isolate commits, not semantics.
- [JS group guard](/quest/m1/js-group-guard.md) - a `@moq/net` publisher abandons a group past its max age without an unhandled rejection
- [Track tail interop](/quest/m1/track-tail-interop.md) - a Rust publisher ending a track with a group in flight is read to its end by the JS subscriber, and the reverse, in `just test interop`
- [Interop flakes](/quest/m1/interop-flakes.md) - the interop harness passes with other runs sharing the machine
+- [Go cancel test](/quest/m1/go-origin-gc.md) - the Go request-cancel test keeps its origin alive, so the collector can't close it mid-test
+- [Worker socket count](/quest/m1/worker-socket-count.md) - the moq-tokio worker test counts only its own listener's sockets
- [Signal.race cleanup](/quest/m1/signal-race.md) - `Signal.race` releases its signal listeners when its result loses a race
- [Binding audio delay](/quest/m1/binding-surface.md) - moq-ffi, libmoq, and every wrapper configure and observe audio playout delay
- [FFI shape](/quest/m1/ffi-shape/README.md) - the bindings mirror Rust's layers: net at the root, then media, json, audio, and video namespaces built from the handle below
- [Track demand](/quest/m1/track-demand.md) - Rust and JS watch a track's subscribers through `demand()` alone
- [Kotlin end](/quest/m1/kotlin-end.md) - Kotlin exposes `close()` as `end()`, since `AutoCloseable.close()` takes the name
+- [Error messages](/quest/m1/error-display.md) - Python, Go, and Dart print `MoqError` with Rust's message, as Kotlin and Swift do
- [Remove finish](/quest/m1/broadcast-remove.md) - on dev, the deprecated broadcast end APIs are gone and `closed()` carries no cause
- [CLI inspection](/quest/m1/cli-inspect/README.md) - `moq ls` lists what is live and `moq fetch` reads a group over MoQ, and a guide shows how to inspect a relay
- [Session close](/quest/m1/session-close.md) - a graceful session end withdraws announces and waits one second for the ack
- [Close codes](/quest/m1/close-codes.md) - a client sees the peer's application close code over WebSocket and raw QUIC, like WebTransport
+- [Raw stream codes](/quest/m1/raw-stream-codes.md) - raw QUIC stream resets and stops carry the application's code, not an HTTP/3-mapped one
- [JS caught up](/quest/m1/js-announce-caught-up.md) - @moq/net's announce consumer says when the initial set has landed, like Rust
+- [Live in apps](/quest/m1/announce-live-apps.md) - the demo and `@moq/room` show "no broadcasts" from the `live` marker, which waits for the first session on page load
- [Bindings caught up](/quest/m1/announce-live-bindings.md) - moq-ffi, libmoq, and every wrapper yield the same flat announce event, `Live` included
- [Optional max age](/quest/m1/ietf-max-age.md) - max age is optional, set only by the publisher, and crosses moq-transport as MAX_CACHE_DURATION
- [IETF announce count](/quest/m1/ietf-announce-count.md) - an opt-in moq-transport extension carries the replay count, so IETF announce consumers go live without a timer
- [Publish delay](/quest/m1/publish-delay.md) - js/publish encoders advertise `delay` behind the earliest rendition, like moq-mux
- [Data jitter](/quest/m1/data-jitter.md) - JSON and binary tracks with a capture time advertise a detected `delay` and `jitter`
+- [Data capture in bindings](/quest/m1/data-capture-bindings.md) - moq-ffi and every wrapper pass a data frame's capture time, and the JSON window producer takes one
- [Moxygen compatibility](/quest/m1/moxygen/README.md) - one subgroup per group, whole-group FETCH, and one datagram per group, never a full moxygen pass
+- [JS IETF datagrams](/quest/m1/js-ietf-datagram.md) - `@moq/net` sends and receives datagram groups over moq-transport, like Rust
- [JavaScript FETCH](/quest/m1/js-fetch.md) - generic on-demand group serving and IETF FETCH for browser publishers
- [Archive](/quest/m1/archive/README.md) - record selected tracks to any object_store and replay them over FETCH or derived HLS, on the catalog and store the release ships
- [Wildcard](/quest/m1/wildcard/README.md) - a relay resolves subscriptions against advertised prefixes, a service claims the prefix it could serve and refuses the rest instead of enumerating broadcasts, and the browser player treats a covering claim as availability
@@ -59,6 +66,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics.
- [mp4-atom dOps mapping](/quest/m1/mp4-atom-dops-mapping.md) - a released mp4-atom reads and writes any `dOps` channel mapping family and table
- [CMAF surround Opus](/quest/m1/cmaf-opus-surround.md) - fMP4 import and export carry an Opus channel mapping table
- [js/hang dOps pre-skip](/quest/m1/js-dops-pre-skip.md) - CMAF encoding in js/hang stops hard-coding a 312-sample pre-skip
+- [GPU CI](/quest/m1/gpu-ci.md) - NVIDIA tests run nightly on a self-hosted GPU runner, and `just rs nvidia` runs them locally instead of skipping
- [GPU pool reservation](/quest/m1/gpu-pool-reservation.md) - a full GPU frame pool is a `None` reservation the caller drops on, not an error to match
- [JS rendition ranking](/quest/m1/js-ranked.md) - `@moq/hang` ranks video renditions like Rust, and `@moq/watch`'s fallback uses it
- [Audio rendition pick](/quest/m1/audio-ranked.md) - single-track FLV/RTMP and WHEP serve the best audio rendition, not the first by name
@@ -97,6 +105,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics.
- [Closure counters](/quest/m1/closure-counters.md) - a departed node's return never regresses the closure counters a consumer already saw
- [RTMP interleaving](/quest/m1/rtmp-interleaving.md) - isolate partial messages before optimizing assembly copies
- [Cache expiry growth](/quest/m1/cache-expiry-growth.md) - with the default pool, relay memory plateaus at the expiry window on every version
+- [Plan: cache age-out](/quest/m1/cache-wall-eviction.md) - a swept benchmark decides whether the track cache ages groups out on wall time without a write
- [Relay memory](/quest/m1/relay-memory.md) - remeasure what an announcement costs after prefix routes
- [PoP skipping](/quest/m1/pop-skipping/README.md) - short cold paths for unpopular broadcasts without losing warm backhaul dedup
- [Route cost in the JS origin](/quest/m1/route-cost.md) - the browser origin ranks routes by cost and hops like Rust instead of newest-first
diff --git a/quest/m1/announce-live-apps.md b/quest/m1/announce-live-apps.md
new file mode 100644
index 0000000000..b9e7a4a995
--- /dev/null
+++ b/quest/m1/announce-live-apps.md
@@ -0,0 +1,42 @@
+# [M] Apps show "no broadcasts" from the live marker
+
+## Goal
+
+A browser page listing broadcasts shows an empty state once the relay has
+said there are none, never a spinner that never resolves and never a false
+empty state before the first session answered. The demo watch page and
+`@moq/room` use `@moq/net`'s `live` marker, and `@moq/net` settles when an
+origin stream opened before the first connection goes live.
+
+## Plan
+
+- #4261 (on `dev`) adds the `live` event; the consumers it touches
+ (`demo/web/src/index.ts`, `js/room/src/room.ts`, `js/watch/src/broadcast.ts`,
+ `js/moq-boy`) skip it today.
+- Page load: an origin stream opened before any session connects has no
+ session to wait on, so today it goes `live` at once and broadcasts arrive
+ after it. Settled: the reconnect loop (`js/net/src/connection/reload.ts`),
+ which already answers requests through `expect()`, holds the marker until
+ its first session lands `live` or its first dial gives up. An empty list
+ then means the relay said so or is unreachable, which a UI can tell apart.
+ Once a session is up, its own `live` ends the hold: every wire guarantees
+ one (ANNOUNCE_OK, ANNOUNCE_INIT, or the quiet-stream fallback). A peer that
+ accepts the announce stream and never answers is a peer bug, so no extra
+ timeout.
+ Check whether Rust's reconnecting client has the same gap.
+- Apps: loading before `live`, an explicit empty state after it with nothing
+ announced, and an error state when the connection gives up. Libraries
+ expose the state as a signal; wording stays in the demo.
+- Tests: an origin stream opened before connect is not `live` until the first
+ session is; a connection that cannot connect ends the wait.
+
+Public API: when `@moq/net` emits `live` changes; any state signal on
+`@moq/room` or `@moq/watch` is additive. Lands on `dev` with #4261. Wire: none.
+
+## Required
+
+- [JS caught up](/quest/m1/js-announce-caught-up.md) - the `live` marker (#4261)
+
+## Related
+
+- [Bindings caught up](/quest/m1/announce-live-bindings.md) - the same marker in the bindings
diff --git a/quest/m1/cache-wall-eviction.md b/quest/m1/cache-wall-eviction.md
new file mode 100644
index 0000000000..6451b320b8
--- /dev/null
+++ b/quest/m1/cache-wall-eviction.md
@@ -0,0 +1,39 @@
+# [S] Plan: wall-clock age-out in the track cache
+
+## Goal
+
+A benchmark decides whether a track's cache ages groups out without waiting
+for a write, on `max(wall, pts)` like other time decisions in moq-net, or
+stays the one write-driven exception. The result is an implementation quest
+or a recorded reason to keep the exception.
+
+## Plan
+
+Settled scope: a track's `max_age` retention aging groups out on a timer
+instead of only on a write. The pool's idle expiry is out of scope.
+
+- Today a track's `max_age` is media time and is applied only when the track
+ writes: a group ages out when a later one starts (`is_stale` and the expiry
+ scans in `rs/moq-net/src/model/track.rs`). A track that stops writing keeps
+ groups past `max_age` until the pool's idle expiry (`Pool::gc`, driven by
+ the origin driver without a write) or byte pressure reclaims them.
+ `max_age_does_not_drive_wall_eviction` pins that behaviour.
+- Prototype the alternative behind a bench-only switch: each track keeps a
+ deadline for its oldest group on `max(wall elapsed, pts)`, armed on the
+ timers the origin driver already runs, and evicts on expiry.
+- Bench in `rs/moq-net/benches/track.rs`, swept over tracks (1 to 10k) and
+ cached groups per track (1 to 1k): write-path cost, timer cost per driver
+ pass, and retained memory for a population of idle tracks. A cost that
+ grows with the table should show as a slope.
+- Weigh the semantics too: `max_age` is media time on purpose, so a congestion
+ stall cannot age content out (`track::Info::max_age`). A wall term changes
+ that for a stalled but live publisher.
+- Record the numbers and the decision in the PR, then rewrite this quest into
+ the implementation or delete it.
+
+Public API: none from the plan. Wire: none.
+
+## Related
+
+- [Cache expiry growth](/quest/m1/cache-expiry-growth.md) - relay memory past the expiry window, in the same cache
+- [Cache shard](/quest/m1/perf/cache-shard.md) - the pool's shared counters under many workers
diff --git a/quest/m1/data-capture-bindings.md b/quest/m1/data-capture-bindings.md
new file mode 100644
index 0000000000..ba9de9d5e5
--- /dev/null
+++ b/quest/m1/data-capture-bindings.md
@@ -0,0 +1,49 @@
+# [M] Bindings stamp data frames with a capture time
+
+## Goal
+
+A moq-ffi publisher, and every wrapper over it (Python, Swift, Kotlin, Go,
+Dart), can pass a capture time with a JSON or binary snapshot `update` or
+stream `append`, so its data tracks advertise `delay` and `jitter` like a Rust
+publisher's. Leaving it out keeps today's behaviour. `moq-json`'s `window`
+producer takes a capture time too. Settled scope: moq-ffi and its wrappers,
+not libmoq.
+
+## Plan
+
+- #4270 gives the Rust producers `moq_net::Timed
`, built with
+ `Timed::from(value).at(t)`. The `moq-mux` data producers take
+ `Timed<_, Instant>`, map it onto the broadcast clock, and refuse one ahead of
+ now (`Error::InvalidCapture`). The moq-ffi producers in
+ `rs/moq-ffi/src/{json,binary}.rs` pass bare values.
+- Settled: the capture time is a media timestamp on the broadcast's
+ timeline. moq-ffi exposes the broadcast clock's `now()` as a timestamp;
+ callers stamp payloads with values taken from it, and moq refuses one ahead
+ of now. That keeps a device or process clock out, as the Rust `Instant`
+ mapping does. The `moq-mux` producers take an `Instant` today, so either
+ map the timestamp back through the clock inside moq-ffi or give the clock a
+ typed timestamp moq-mux accepts; keep a raw `Timestamp` from compiling
+ there. Make it optional on the existing methods, never a `_with_capture`
+ twin.
+- `moq-json` window: `window::Producer::push` stamps `Timestamp::now()`
+ (`rs/moq-json/src/window/producer.rs`). Accept `Timed` as the snapshot and
+ stream producers do. Nothing in `moq-mux` publishes window mode, so there is
+ no estimator to feed.
+- Wrappers follow per the cross-package sync table, each with a test that a
+ past capture time is accepted and a future one refused. Update
+ `doc/lib/{py,swift,kt,go,dart}`.
+
+Public API: breaking, so it lands on `dev`. A new parameter on the generated
+`update` and `append` breaks every published binding caller (Go, for one, has
+no optional arguments), and a `_with_x` twin is ruled out. The broadcast clock
+`now()` is additive; `window::Producer::push` accepts `Timed`, source-compatible.
+Wire: none.
+
+## Required
+
+- [Data jitter](/quest/m1/data-jitter.md) - `Timed` and the mux capture path (#4270)
+
+## Related
+
+- [FFI shape](/quest/m1/ffi-shape/README.md) - moves the data producers into a json namespace
+- [Generated C bindings](/quest/m1/c/README.md) - replaces libmoq, so C inherits this from moq-ffi
diff --git a/quest/m1/error-display.md b/quest/m1/error-display.md
new file mode 100644
index 0000000000..894f3581d0
--- /dev/null
+++ b/quest/m1/error-display.md
@@ -0,0 +1,34 @@
+# [M] Every binding prints MoqError's message
+
+## Goal
+
+Python's `str(err)`, Go's `err.Error()`, and Dart's `toString()` on a
+`MoqError` return the message from moq-ffi's exported `Display`, as Kotlin's
+`toString()` and Swift's `description` already do. No hand-written
+per-variant strings and no extra message function.
+
+## Plan
+
+- #4292 adds `#[uniffi::export(Display)]` on `MoqError` in
+ `rs/moq-ffi/src/error.rs`, on the C++ line. If it has not reached `main`
+ when this starts, add the same attribute here; it is additive.
+- Go and Dart are fixed in their generators; Python in its wrapper:
+ - Go: the `kixelated/uniffi-bindgen-go` fork. The generated `Error()` prints
+ `MoqError: `. Render the exported `Display` for errors, tag, and
+ bump every pin site the `flake.nix` comment lists.
+ - Dart: the `kixelated/uniffi-dart` fork. The regenerated bindings already
+ carry the unused extern; wire it to `toString()`, tag, bump `flake.nix`,
+ and regenerate `dart/moq_ffi`.
+ - Python: upstream `mozilla/uniffi-rs` (0.32.2 here) renders no uniffi
+ traits on errors (`ErrorTemplate.py`). Settled: no upstream PR. The
+ hand-written `py/moq-rs` package defines `__str__` on `MoqError` by
+ calling the exported `Display`, so the message still comes from Rust.
+- A test per binding that a known error prints Rust's text (`Closed` prints
+ `closed`), and `doc/lib/{py,go,dart}` updated where they show error output.
+
+Public API: the string form of `MoqError` changes in Python, Go, and Dart.
+Wire: none.
+
+## Related
+
+- [C++ through moq-ffi](/quest/m1/cpp/README.md) - where #4292 adds the export and C++ `to_string()`
diff --git a/quest/m1/go-origin-gc.md b/quest/m1/go-origin-gc.md
new file mode 100644
index 0000000000..25bc6aae9e
--- /dev/null
+++ b/quest/m1/go-origin-gc.md
@@ -0,0 +1,32 @@
+# [XS] Go cancel test keeps its origin
+
+## Goal
+
+`TestRequestBroadcastCancelKeepsTheOrigin` in `go/wrapper/moq_test.go` passes
+under load, not only alone (30/30 in isolation, `MoqError: Closed` under a
+loaded run). Fixed at the cause, with no retry or longer timeout.
+
+## Plan
+
+Likely cause, found by reading and not yet reproduced: the test never touches
+`origin` after `origin.Dynamic(...)` and `origin.Consume()`, so Go may collect
+it mid-test. The generated finalizer then drops the only `MoqOriginProducer`,
+the origin's driver finishes once every producer handle is gone
+(`origin::Driver::poll` in `rs/moq-net/src/model/origin.rs`), and the next call
+on the consumer or the dynamic handle gets `Error::Closed`. The collector runs
+more often under load, which fits.
+
+- Reproduce first. `runtime.GC()` after `cancel()` or `GOGC=1` only makes the
+ collection likely: finalizers run later on their own goroutine. For a
+ deterministic failure, do what the finalizer does: an internal test calls
+ the inner handle's `Destroy` before the next call.
+- Keep the producer reachable to the end of the test (`runtime.KeepAlive`, as
+ the file already does for `pending`), and audit the other `go/wrapper` tests
+ that hold an `OriginProducer` only for setup.
+- Users hit the same trap: a Go `OriginProducer` has no `Close`, so its origin
+ ends whenever the collector reaches it. Say so in its doc comment and
+ `doc/lib/go`. Whether consumers should keep their producer alive, or the
+ wrapper should gain an explicit `Close`, is an API call: propose it to the
+ maintainer rather than ship it here.
+
+Public API: none unless the maintainer picks an API change. Wire: none.
diff --git a/quest/m1/gpu-ci.md b/quest/m1/gpu-ci.md
new file mode 100644
index 0000000000..0c0a8a054e
--- /dev/null
+++ b/quest/m1/gpu-ci.md
@@ -0,0 +1,57 @@
+# [S] NVIDIA tests run on real hardware
+
+## Goal
+
+The NVDEC, NVENC, and CUDA tests run nightly on the maintainer's Linux host
+(RTX 3070 Ti) instead of passing without a GPU on hosted runners, and a local
+`just rs nvidia` runs them against the host driver instead of silently
+skipping inside the Nix shell.
+
+## Plan
+
+- Why they skip: the driver libraries are loaded at runtime (`libcuda` by
+ cudarc, `libnvidia-encode` in `rs/moq-nvenc/src/safe/api.rs`, `libnvcuvid`
+ in `rs/moq-nvenc/src/cuvid.rs`), and the tests return early when they are
+ missing (`hw_available` in `rs/moq-video/src/decode/backend/nvdec.rs`). The
+ Nix shell's loader path lacks Ubuntu's `/usr/lib/x86_64-linux-gnu`.
+- Select every test that needs the GPU, not only names containing `nvdec`,
+ `nvenc`, or `cuda`: `safe::session::tests::failed_submission_releases_the_session`
+ in `rs/moq-nvenc` needs hardware and matches none of them. Find them by
+ their driver probes (`hw_available`, `driver_libs_present`, `Api::get` and
+ friends in `moq-nvenc` and `moq-video`). Recommendation: follow the
+ existing `#[ignore = "requires ..."]` convention (as `frame/vulkan_test.rs`
+ does) and put them in `nvidia` test modules, so hosted CI reports them
+ ignored instead of passed and one filter, `--run-ignored only -E
+ 'test(/::nvidia::/)'`, selects them all without the other ignored hardware
+ tests (Android, D3D11, PipeWire). Inside that selection a missing GPU fails
+ the test instead of returning early. Keep the no-driver tests
+ (`missing_driver_errors_instead_of_panicking`) outside it.
+- `just rs nvidia`: symlink only those three libraries (by soname) from
+ `/usr/lib/x86_64-linux-gnu` into a private directory, put that on
+ `LD_LIBRARY_PATH`, and run that selection. Fail when a library is missing
+ instead of skipping. `just rs vulkan-cuda` puts the whole host directory on
+ the path, which lets host libraries shadow the Nix ones; fold it into this
+ recipe, since its `vulkan_cuda_` tests are the same kind. Those also need
+ the Vulkan loader to find the host NVIDIA ICD: point it at the ICD manifest
+ and expose the driver libraries it names, or keep them in their own recipe.
+- Nightly: a job in `.github/workflows/nightly.yml` runs `just rs nvidia` on
+ the self-hosted runner. A self-hosted runner on a public repository must
+ never run untrusted code: only `schedule` and `workflow_dispatch`, with the
+ job gated to `refs/heads/main`, never `pull_request`; a dedicated label only
+ this job selects; read-only `permissions`. Read GitHub's self-hosted runner
+ hardening guidance before wiring it.
+- Share the runner with the io_uring one that #4132 plans
+ (`quest/m1/uring-runner.md` on the drain line, which wants a 6.12+ kernel on
+ the same host): one registration and one security posture, a label per
+ capability. Whichever quest lands second reuses the first's job shape.
+
+Public API: none. Wire: none.
+
+## Required
+
+- A self-hosted runner is registered for moq-dev/moq on the maintainer's host, with the NVIDIA driver
+
+## Related
+
+- [Video hardware validation](/quest/m3/video-hardware.md) - hardware paths nothing runs yet
+- [Runtime QA hosts](/quest/m2/runtime-qa-hosts.md) - on-demand jobs on hardware hosts, a broader contract than a nightly
diff --git a/quest/m1/js-ietf-datagram.md b/quest/m1/js-ietf-datagram.md
new file mode 100644
index 0000000000..417609fe0a
--- /dev/null
+++ b/quest/m1/js-ietf-datagram.md
@@ -0,0 +1,29 @@
+# [M] @moq/net datagrams over moq-transport
+
+## Goal
+
+A `@moq/net` session on moq-transport sends and receives datagram groups as
+`OBJECT_DATAGRAM`, one object per group with its sequence kept, as Rust does.
+A datagram track crosses IETF between Rust and JS in both directions in
+`just test interop`.
+
+## Plan
+
+- JS routes datagrams on moq-lite only (`js/net/src/lite/datagram.ts`,
+ `runDatagrams` from lite-05); `js/net/src/ietf/` reads and writes none.
+ Mirror the lite path on the IETF session and the Rust mapping from #4274:
+ receive decodes `OBJECT_DATAGRAM` and inserts on the aliased subscription's
+ track; send writes object 0 with END_OF_GROUP, the explicit publisher
+ priority, and the timestamp property when the track has a timescale.
+- Keep Rust's edges: an Object ID other than 0, a non-Normal status, or an
+ unbound alias is dropped; a malformed Type closes the session. Rust covers
+ drafts 14 and later, whose Type flags differ between 14 and 15+; decide
+ what draft 07, which JS also speaks, does.
+- Add IETF datagram cases beside the lite ones in the interop harness.
+
+Public API: none expected. Wire: `@moq/net` moq-transport sessions send and
+accept `OBJECT_DATAGRAM` as the drafts define; no project draft changes.
+
+## Required
+
+- [Datagram groups](/quest/m1/moxygen/datagram.md) - the Rust side this mirrors and interops with
diff --git a/quest/m1/qos/README.md b/quest/m1/qos/README.md
index b6c84121b6..a5fe3fa32b 100644
--- a/quest/m1/qos/README.md
+++ b/quest/m1/qos/README.md
@@ -36,6 +36,10 @@ preflight, consumes them downstream.
- [Starvation](/quest/m1/qos/starvation.md) - per broadcast, how far behind
the acknowledged frontier of its subscriptions is, in media time, plus the
media dropped before it was acknowledged
+- [Final lag sample](/quest/m1/qos/final-lag-sample.md) - a closing
+ subscription records its last partial interval instead of losing it
+- [Lag dashboard](/quest/m1/qos/lag-dashboard.md) - the demo stats
+ dashboard shows viewer lag percentiles and dropped media
- [Starvation at frame granularity](/quest/m1/qos/starvation-frames.md) - the
acknowledged frontier moves at every frame boundary through `poll_acked`,
with a delivery-delay histogram for jitter
diff --git a/quest/m1/qos/final-lag-sample.md b/quest/m1/qos/final-lag-sample.md
new file mode 100644
index 0000000000..6ee05196af
--- /dev/null
+++ b/quest/m1/qos/final-lag-sample.md
@@ -0,0 +1,31 @@
+# [XS] A closing subscription takes its last lag sample
+
+## Goal
+
+When an egress subscription ends, the bytes its track produced since the last
+stats tick still land in the `lag` histogram at its final lag, instead of
+being lost. A subscription that opens and closes between two ticks is sampled
+at least once.
+
+## Plan
+
+Lag is sampled only on `Registry::report` ticks: `Counters::sample` in
+`rs/moq-net/src/stats.rs` walks weak `FrontierInner` references and prunes
+the dead ones without sampling them, so a subscription's last partial
+interval disappears with it.
+
+- The subscription guard and each in-flight group `Delivery` hold a strong
+ reference, so the frontier dies only once the last of them does. Its drop is
+ the natural place for one final `sample(now)` into the same counters.
+- No double counting: `sample` advances each source's `sampled` bytes under the
+ frontier lock, so a tick racing the drop leaves nothing for it to count again.
+- Test: a subscription that opens and closes between ticks while its track
+ produces lands those bytes in the histogram; one that closes mid-interval
+ adds exactly the bytes since its last sample. Note the behaviour in
+ `doc/concept/stats.md`.
+
+Public API: none. Wire: none, only the histogram's values change.
+
+## Required
+
+- [Starvation](/quest/m1/qos/starvation.md) - the sampler this extends (#4298)
diff --git a/quest/m1/qos/lag-dashboard.md b/quest/m1/qos/lag-dashboard.md
new file mode 100644
index 0000000000..c89e45f38f
--- /dev/null
+++ b/quest/m1/qos/lag-dashboard.md
@@ -0,0 +1,34 @@
+# [S] Demo dashboard shows viewer lag
+
+## Goal
+
+The demo stats dashboard (`demo/web/src/stats.ts`) shows the relay's viewer
+lag and dropped media: percentiles of the egress `lag` histogram over the
+chart window, and `dropped` duration, bytes, and groups as rates, cluster-wide
+and per node like the existing counters.
+
+## Plan
+
+- The relay writes both on `publisher.json` rows only. `lag` is a cumulative
+ byte count per bucket keyed by its upper edge (`"50ms"` to `"5s"`, then
+ `"inf"`, empty buckets omitted); `dropped` is `{ duration, bytes, groups }`
+ with the duration in fractional milliseconds. `doc/concept/stats.md` on the
+ QoS line documents them. Diff two samples for an interval's distribution,
+ as the dashboard already does to turn cumulative bytes into rates, and sum
+ nodes bucket by bucket.
+- A percentile read from buckets is a bucket edge, not a point value, and
+ `inf` has no upper edge. Show it as "under X" or interpolate inside the
+ bucket, and say which.
+- Skip `.`-prefixed system broadcasts, as the existing aggregate does. Lag is
+ per broadcast, so a per-broadcast view is worth adding if it stays cheap.
+- The [browser stats quest](/quest/m1/qos/stats/js.md) moves the dashboard
+ onto `@moq/stats` on the same line. If it has landed, read `lag` and
+ `dropped` through its schemas; otherwise extend the existing interfaces and
+ let whichever lands second reconcile.
+
+Public API: none. Wire: none.
+
+## Required
+
+- [Starvation](/quest/m1/qos/starvation.md) - the `lag` histogram and
+ `dropped` counters (#4298)
diff --git a/quest/m1/raw-stream-codes.md b/quest/m1/raw-stream-codes.md
new file mode 100644
index 0000000000..a4c1b517da
--- /dev/null
+++ b/quest/m1/raw-stream-codes.md
@@ -0,0 +1,38 @@
+# [M] Raw QUIC stream codes stay raw
+
+## Goal
+
+On a raw QUIC session (`moqt://`, `moql://`, raw iroh), RESET_STREAM and
+STOP_SENDING carry the application's code as-is, not mapped through the
+HTTP/3 WebTransport code space, so moq agrees with other raw QUIC MoQ stacks
+on stream errors. WebTransport sessions keep the mapping they need.
+
+## Plan
+
+- `web-transport-moq` (moq-dev/noq) runs every stream code through
+ `web_transport_proto::error_to_http3` and `error_from_http3` in `send.rs`
+ and `recv.rs`, whether or not the session came from `Session::raw`.
+ `web-transport-iroh` and `web-transport-quinn` (moq-dev/web-transport) do
+ the same. A raw peer's code 5 reads as `None` or another value, and ours
+ reaches it as a large HTTP/3 code.
+- [Close codes](/quest/m1/close-codes.md) fixed the same mix-up for
+ `ApplicationClosed` (noq#11, #4262). Let each stream know whether its
+ session is raw and skip the mapping there, in all three adapters, with a
+ round-trip test per adapter against a plain QUIC peer.
+- Release the fixed crates and bump the pins here in the same quest; published
+ crates depend on crates.io releases, never a patch. A moq-tokio test over
+ `moqt://` asserts a reset code arrives verbatim, beside `close_code.rs`.
+- Mixed versions: two moq peers on raw QUIC agree today because both map, and
+ wire changes must stay compatible with published versions. A fixed peer can
+ read both forms, since a mapped code lands in the HTTP/3 WebTransport range
+ that no application code reaches. An older peer misreads a fixed peer's raw
+ codes. Check which codes moq-net acts on (group stream resets, subscribe
+ STOP_SENDING): if any drives behaviour beyond reporting, this is a wire
+ break and retargets to `dev`.
+
+Public API: none expected. Wire: raw QUIC stream error codes become the
+application's own values; compatible only if older peers merely report them.
+
+## Required
+
+- [Close codes](/quest/m1/close-codes.md) - the adapter releases and trait 0.5 this builds on
diff --git a/quest/m1/worker-socket-count.md b/quest/m1/worker-socket-count.md
new file mode 100644
index 0000000000..dd6b549c2f
--- /dev/null
+++ b/quest/m1/worker-socket-count.md
@@ -0,0 +1,25 @@
+# [XS] Worker socket count sees only its own listener
+
+## Goal
+
+`dropping_a_server_keeps_its_socket` in `rs/moq-tokio/tests/worker.rs` counts
+only the sockets its own listener holds, so an unrelated UDP socket elsewhere
+on the machine can't fail it.
+
+## Plan
+
+`udp_sockets_on(port)` counts every `/proc/net/udp` entry whose local address
+ends in the port, so another process's socket on the same port number but a
+different address (an ephemeral port on another interface, say) breaks both
+the `>= 2` assertion and the final `== 0`.
+
+- The listener binds `127.0.0.1:0` (`listen_config`), so match the full local
+ address rather than the port suffix.
+- The final `== 0` runs after the port is released, when a parallel test may
+ bind the same address. If that window matters, count only this process's
+ sockets by joining the table's inode column with `/proc/self/fd`. Prefer
+ whichever holds with a stranger on the port at every assertion.
+- Reproduce by holding a UDP socket on the same port on another local address
+ while the test runs.
+
+Public API: none. Wire: none.