@moqtap/trace 0.2.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SPEC.md CHANGED
@@ -1,129 +1,112 @@
1
- # `.moqtrace` File Format Specification
1
+ # `.moqtrace` Specification
2
2
 
3
- > **Version:** 1
3
+ > **Version:** 2
4
4
  > **Status:** Draft
5
5
 
6
6
  ## Overview
7
7
 
8
- `.moqtrace` is a binary file format for recording MoQT (Media over QUIC Transport) sessions. It captures protocol-level events at configurable detail levels, from lightweight control-message-only traces to full wire-level recordings with payload bytes.
8
+ This document defines two layered artifacts:
9
9
 
10
- The format is designed to be:
10
+ 1. **The MoQT trace event model** (Part 1, normative) — the set of event types, their fields, and their semantics. This model is transport-independent; it can be serialized to a file, streamed over MoQT, shipped as JSON to a collector, or embedded in another container.
11
+ 2. **The `.moqtrace` binary file format** (Part 2, normative) — a CBOR-based container for the event model above. Streamable, segmentable, self-describing.
12
+
13
+ A third artifact, the MoQT carriage convention for live trace transport, is **Appendix A (informative)**. It is transport behavior rather than file-format core and is expected to migrate to a separate moqtap protocol draft; it is included here only so early implementers have a single reference.
14
+
15
+ Design goals of the `.moqtrace` container:
11
16
 
12
17
  - **Streamable** — events can be appended as they occur; no backpatching required
18
+ - **Segmentable** — a stream can be carved into self-contained segments, each independently parseable, enabling live carriage and capture rotation
13
19
  - **Compact** — CBOR encoding handles binary data natively (no base64 overhead)
14
20
  - **Self-describing** — the header declares the detail level, protocol version, and recording context so readers know what to expect without scanning events
15
21
  - **Cross-language** — readable by any language with a CBOR library (JS, Rust, Go, Python)
16
22
 
17
- ## File Layout
18
-
19
- ```
20
- Offset Length Content
21
- ------ ------ -------
22
- 0 8 Magic bytes: "MOQTRACE" (0x4d4f515452414345)
23
- 8 4 Format version (uint32 LE) — currently 1
24
- 12 4 Header length (uint32 LE) — byte length of the CBOR header blob
25
- 16 N Header (CBOR map) — session metadata and recording configuration
26
- 16+N ... Event stream (CBOR sequence) — concatenated CBOR items, one per event
27
- ```
28
-
29
- ### Magic Bytes
30
-
31
- The 8-byte ASCII string `MOQTRACE` identifies the file format. Any tool can check these bytes before attempting to parse.
32
-
33
- ### Format Version
34
-
35
- uint32 little-endian. Currently `1`. Readers MUST reject files with a version they don't support.
36
-
37
- ### Header Length
38
-
39
- uint32 little-endian. The byte length of the CBOR-encoded header that immediately follows. This allows readers to extract metadata without scanning the event stream — useful for file browsers, search indexing, and quick filtering ("show me all draft-14 client traces").
40
-
41
- ## Header
42
-
43
- A single CBOR map with the following keys:
44
-
45
- | Key | CBOR Type | Required | Description |
46
- | --------------- | ----------- | -------- | --------------------------------------------------------------------------------------------- |
47
- | `"protocol"` | text string | Yes | MoQT version identifier (e.g., `"draft-14"`, `"rfc9999"`) |
48
- | `"perspective"` | text string | Yes | Recording viewpoint: `"client"`, `"server"`, or `"observer"` |
49
- | `"detail"` | text string | Yes | Detail level (see below) |
50
- | `"startTime"` | integer | Yes | Recording start time (Unix epoch milliseconds) |
51
- | `"transport"` | text string | No | Transport type (e.g., `"webtransport"`, `"raw-quic"`) |
52
- | `"endTime"` | integer | No | Recording end time (Unix epoch milliseconds). Written when trace is finalized. |
53
- | `"source"` | text string | No | Software that produced the trace (e.g., `"moqtap-devtools/0.1.0"`, `"my-relay/2.3.1"`) |
54
- | `"endpoint"` | text string | No | Remote peer URI (e.g., `"https://relay.example.com/moq"`) |
55
- | `"sessionId"` | text string | No | Opaque identifier for correlating traces from the same session across multiple capture points |
56
- | `"custom"` | map | No | User-defined metadata (arbitrary key-value pairs) |
23
+ ---
57
24
 
58
- ### Protocol Identifier
59
-
60
- The `"protocol"` field identifies the MoQT version without assuming lifecycle stage. Use the IETF document name minus the `draft-ietf-` prefix for drafts, and the RFC number for published standards:
61
-
62
- - Draft phase: `"moq-transport-07"`, `"moq-transport-14"`
63
- - RFC phase: `"moq-transport-rfc9999"`
64
-
65
- ### Perspective
66
-
67
- The `"perspective"` field describes the vantage point of the recording:
68
-
69
- - `"client"` — captured at the MoQT client (initiator of the QUIC connection)
70
- - `"server"` — captured at the MoQT server or relay
71
- - `"observer"` — passive capture (e.g., DevTools extension, network tap) that intercepts traffic without participating in the session
25
+ # Part 1: Event Model (normative)
72
26
 
73
- ### Detail Levels
27
+ ## Detail Levels
74
28
 
75
- The `"detail"` field declares what was recorded. Each level is a strict superset of the one above it. Readers can handle any level — they just find more or fewer fields populated per event.
29
+ The `"detail"` field (see Part 2 header) declares what was recorded. Each level is a strict superset of the one above it. Readers can handle any level — they just find more or fewer fields populated per event.
76
30
 
77
31
  | Detail Level | What's Recorded | Typical Use Case |
78
32
  | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- |
79
33
  | `"control"` | Control messages only (setup, subscribe, publish, goaway, etc.). No data stream events, no object payloads. | Lightweight protocol flow analysis. DevTools default. |
80
34
  | `"headers"` | Control messages + data stream headers (subgroup/fetch/datagram headers, object metadata: group, object ID, priority, status). No payload bytes. | Delivery pattern analysis, timing. |
81
- | `"headers+sizes"` | Everything in `"headers"` + payload byte lengths for each object. | Bandwidth analysis without storing media. |
35
+ | `"headers+sizes"` | Everything in `"headers"` + payload byte lengths for each object, and the size of the bytes behind an error ([Event 6](#event-6-error) `"rawlen"`). | Bandwidth analysis without storing media. |
82
36
  | `"headers+data"` | Everything in `"headers"` + full payload bytes for each object. | Full session replay, debugging media corruption. |
83
- | `"full"` | Everything above + raw wire bytes for every message (pre-decode). | Wire-level debugging, compliance testing. |
37
+ | `"full"` | Everything above + raw wire bytes for every *control* message (pre-decode), and the bytes behind an error event ([Event 6](#event-6-error) `"raw"`). See the note below on data-stream framing. | Wire-level debugging, compliance testing. |
84
38
 
85
- At any detail level, the recorder MAY be configured to **mask payloads** — replacing payload bytes with zeroes before writing. This preserves payload sizes for bandwidth analysis while stripping media content. The header SHOULD include `"payloadMasked": true` in the `"custom"` map when this is active.
39
+ Levels `"headers+data"` and `"full"`, and the `"raw"` field on Event 0 and [Event 6](#event-6-error), are **payload-bearing** — see [Privacy Considerations](#privacy-considerations). So is [Event 6](#event-6-error)'s `"raw"`, which is why it is gated at `"full"` and not at Event 6's own `control`+: an error naming a *data* stream has subgroup framing and object payload behind it, which is media, and inheriting the event's level would have put it in traces whose declared level excludes payloads entirely.
86
40
 
87
- ## Event Stream
41
+ **No level carries data-stream framing bytes.** `"raw"` exists only on Event 0,
42
+ and Event 4's `"pl"` begins after the object header, so the bytes of a
43
+ `SUBGROUP_HEADER`, a fetch header or a datagram header are recorded at no level
44
+ including `"full"`. What those headers carry is recorded as decoded fields
45
+ instead — see [Event 1](#event-1-stream-opened). Closing that gap needs a new
46
+ key and is not in this revision; the level's description no longer implies
47
+ otherwise.
48
+
49
+ At any detail level, the recorder MAY be configured to **mask payloads** — replacing payload bytes with zeroes before writing. This preserves payload sizes for bandwidth analysis while stripping media content. Sources MUST advertise masking via `"payloadMasked": true` in the header's `"custom"` map when active.
50
+
51
+ ## Event Types
88
52
 
89
- After the header, the remainder of the file is a sequence of concatenated CBOR items (one per event). This is a standard CBOR sequence (RFC 8742) — no array wrapper, no separators.
53
+ | `"e"` Value | Name | Description |
54
+ | ----------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------ |
55
+ | `0` | Control message | A control-stream message was sent or received |
56
+ | `1` | Stream opened | A unidirectional or bidirectional QUIC stream was opened |
57
+ | `2` | Stream closed | A QUIC stream was closed |
58
+ | `3` | Object header | An object header was parsed from a data stream |
59
+ | `4` | Object payload | Object payload bytes were received/sent |
60
+ | `5` | State change | Session FSM phase transition |
61
+ | `6` | Error | Protocol error or transport error |
62
+ | `7` | Annotation | User-defined event (custom label + data) |
63
+ | `8` | Peer connected | A new peer session was established (relay-tap) |
64
+ | `9` | Peer disconnected | A peer session ended (relay-tap) |
65
+ | `10` | Subscription derivation | A subscription was created or extended in causal response to other subscriptions (relay-tap; multi-hop correlation) |
90
66
 
91
- ### Common Event Fields
67
+ Event types `8`, `9` and `10` are defined by this version but are emitted only by `"relay-tap"` sources. A reader MUST be able to parse them; a recorder that is not a relay tap never produces them.
68
+
69
+ ## Common Event Fields
92
70
 
93
71
  Every event is a CBOR map with at minimum:
94
72
 
95
73
  | Key | CBOR Type | Description |
96
74
  | ----- | ---------------- | --------------------------------------------------------------------------------------------------- |
97
- | `"n"` | unsigned integer | Monotonically increasing sequence number (0-based). Disambiguates events with identical timestamps. |
98
- | `"t"` | integer | Timestamp in microseconds since `startTime` in the header |
99
- | `"e"` | integer | Event type (see table below) |
75
+ | `"n"` | unsigned integer | Monotonically increasing sequence number (0-based). Disambiguates events with identical timestamps. Segment-local in segmented traces. |
76
+ | `"t"` | integer | Timestamp in microseconds since the containing segment's `startTime`. |
77
+ | `"e"` | integer | Event type (see table above). |
78
+ | `"p"` | text string | **Optional.** Peer identifier. REQUIRED when `"perspective": "relay-tap"`; identifies which connected peer the event pertains to. Source-local scope — see [Identifier Scoping](#identifier-scoping). |
100
79
 
101
80
  Short key names are used intentionally — traces can contain hundreds of thousands of events, and CBOR encodes short strings more compactly.
102
81
 
103
- ### Event Types
82
+ ## Identifier Scoping
83
+
84
+ The event model uses three distinct identifiers with different scopes. Tooling MUST NOT conflate them.
104
85
 
105
- | `"e"` Value | Name | Description |
106
- | ----------- | --------------- | -------------------------------------------------------- |
107
- | `0` | Control message | A control-stream message was sent or received |
108
- | `1` | Stream opened | A unidirectional or bidirectional QUIC stream was opened |
109
- | `2` | Stream closed | A QUIC stream was closed |
110
- | `3` | Object header | An object header was parsed from a data stream |
111
- | `4` | Object payload | Object payload bytes were received/sent |
112
- | `5` | State change | Session FSM phase transition |
113
- | `6` | Error | Protocol error or transport error |
114
- | `7` | Annotation | User-defined event (custom label + data) |
86
+ | Identifier | Scope | Assigned by | Purpose |
87
+ | -------------- | ---------------------------------------------------- | ------------------------------------------------- | -------------------------------------------------------------- |
88
+ | `"p"` | **Source-local** — unique only within one trace source (one recorder, one `sessionId`) | The recording source; format is source-chosen (UUID, numeric ID, `version@addr`, etc.) | Demultiplexing events from multiple peers within a relay-tap trace. |
89
+ | `"sessionId"` | **Capture-correlation** — groups traces recorded from a single logical session by different capture points (e.g., client + server + observer) | Chosen out-of-band by the operator, or propagated via session setup | Aligning multiple viewpoints on the **same** session. |
90
+ | `"traceId"` | **End-to-end / federated** — globally unique across operator boundaries | The originating endpoint, propagated on-wire via `MOQTAP_TRACE_ID` | Stitching multi-hop subscription chains into a single tree, even across organizations. |
115
91
 
116
- ### Event-Specific Fields
92
+ Because `"p"` is source-local, a collector correlating traces from multiple sources MUST NOT assume that the same `"p"` value in two different traces refers to the same peer. To cross-reference peers across sources, use `"traceId"` (for subscription-linked events) or compose `(source, p)` as the effective key (using the header's `"source"` field).
117
93
 
118
- #### Event 0: Control Message
94
+ `"sessionId"` and `"traceId"` are **orthogonal**: a single session may carry many subscriptions each with a distinct trace ID, and a single end-to-end trace may span many sessions on many hosts. Presence rules:
119
95
 
120
- | Key | Type | Detail Level | Description |
121
- | ------- | ----------- | ------------ | ---------------------------------------------------------------- |
122
- | `"d"` | integer | `control`+ | Direction: `0` = sent (tx), `1` = received (rx) |
123
- | `"mt"` | integer | `control`+ | Wire message type ID (e.g., `0x03` for SUBSCRIBE) |
124
- | `"msg"` | map | `control`+ | Decoded message fields (draft-specific structure) |
125
- | `"sid"` | integer | `control`+ | QUIC stream ID the message travelled on. Optional (see below). |
126
- | `"raw"` | byte string | `full` only | Raw wire bytes (including type and length prefix) |
96
+ - `"sessionId"` SHOULD be set whenever the recording party has a stable session handle and more than one vantage point is likely to be captured (e.g., client-side and server-side of the same WebTransport session). It MAY be absent in single-vantage captures.
97
+ - `"traceId"` appears **only on events derived from a SUBSCRIBE carrying `MOQTAP_TRACE_ID`**. It is never required on the header. An event with no triggering subscription (e.g., Event 8 Peer Connected) has no trace ID.
98
+
99
+ ## Event-Specific Fields
100
+
101
+ ### Event 0: Control Message
102
+
103
+ | Key | Type | Detail Level | Description |
104
+ | ------- | ----------- | ------------ | ------------------------------------------------- |
105
+ | `"d"` | integer | `control`+ | Direction: `0` = sent (tx), `1` = received (rx) |
106
+ | `"mt"` | integer | `control`+ | Wire message type ID (e.g., `0x03` for SUBSCRIBE) |
107
+ | `"msg"` | map | `control`+ | Decoded message fields, keyed in snake_case (see below) |
108
+ | `"sid"` | unsigned integer | `control`+ | QUIC stream ID the message travelled on. Optional (see below). |
109
+ | `"raw"` | byte string | `full` only | Raw wire bytes (including type and length prefix). Payload-bearing — see [Privacy Considerations](#privacy-considerations). |
127
110
 
128
111
  `"sid"` is optional: a recorder that sits at the session level rather than the
129
112
  stream level has no stream to report, and omitting the key says so. Readers
@@ -139,22 +122,135 @@ track names, aliases, request outcomes — is lost to the reader. Through
139
122
  draft-16 all control messages share one stream and the field is merely
140
123
  informative.
141
124
 
142
- #### Event 1: Stream Opened
125
+ #### `"msg"` field naming and shape
126
+
127
+ `"msg"` MUST be a CBOR map. Its keys are the message's field names **in
128
+ snake_case** — `request_id`, `track_alias`, `group_order` — which is what the
129
+ drafts themselves use and what the shared codec vectors carry, so a reader can
130
+ address a field without knowing which implementation wrote the file.
131
+
132
+ A writer that has no decoded fields — the message type is one it cannot parse,
133
+ or the recorder is not decoding bodies at all — MUST write an empty map `{}`
134
+ rather than omit the key. Omission costs a reader more than the one byte it
135
+ saves: Event 0 is one of the types [sampling](#sampling) MUST NOT drop, so a
136
+ reader that treats a missing `"msg"` as a malformed event discards exactly the
137
+ events the format promises to keep.
138
+
139
+ Readers MUST be more tolerant than writers, because files predating this rule
140
+ exist:
141
+
142
+ - An **absent** `"msg"` MUST be read as an empty map, not as an error.
143
+ - A `"msg"` that is **not a map** MUST be preserved verbatim and offered to the
144
+ caller unchanged. It MUST NOT cause the event to be rejected. Recordings
145
+ written before this revision carry a text rendering of the decoded message
146
+ here — every `capture-*` case in the conformance corpus is such a file — and
147
+ they remain readable, with the field simply not addressable by key.
148
+
149
+ CBOR `null` is a value, not an absence: a writer put it there, so it falls
150
+ under "not a map" and MUST be preserved. CBOR `undefined` (major type 7,
151
+ simple value 23) is **not** preservable here, and writers MUST NOT emit it.
152
+ Neither reference implementation can represent it distinctly *as a `"msg"`* —
153
+ the TypeScript reader cannot tell it from a field the caller never set, and the
154
+ Rust reader's CBOR library decodes it to `null` before the reader sees it — so
155
+ a reader MAY normalise it to an empty map or to `null`, and the two will
156
+ disagree about which. There is no file this matters for unless one is written
157
+ deliberately; the rule exists so that nobody writes one expecting it to
158
+ survive. It is one row of a general table — see [Shapes a CBOR library may
159
+ normalise before you see them](#shapes-a-cbor-library-may-normalise-before-you-see-them),
160
+ which also explains why the answer differs by *where* in a trace the value sits:
161
+ the same `undefined` inside an unrecognised-key store is preserved by one
162
+ implementation and not the other.
163
+
164
+ **A tool that reads a trace and writes it back MUST preserve a non-map `"msg"`
165
+ as it found it**, and MUST NOT replace it with an empty map to satisfy the
166
+ writer rule above. Replacing it would discard the only record of a message that
167
+ will never be seen again. This is one instance of a general rule stated under
168
+ [Versioning and Compatibility](#versioning-and-compatibility): on a rewrite the
169
+ reader rule outranks the writer rule.
170
+
171
+ ### Event 1: Stream Opened
143
172
 
144
173
  | Key | Type | Detail Level | Description |
145
174
  | ------- | ------- | ------------ | -------------------------------------------------------- |
146
175
  | `"sid"` | integer | `headers`+ | QUIC stream ID |
147
176
  | `"d"` | integer | `headers`+ | Direction: `0` = outgoing, `1` = incoming |
148
177
  | `"st"` | integer | `headers`+ | Stream type: `0` = subgroup, `1` = datagram, `2` = fetch |
149
-
150
- #### Event 2: Stream Closed
178
+ | `"ta"` | integer | `headers`+ | Track alias the stream carries. Optional. |
179
+ | `"sg"` | integer | `headers`+ | Subgroup ID. Optional; only meaningful when `st == 0`. |
180
+ | `"fri"` | integer | `headers`+ | Fetch request ID. Optional; only meaningful when `st == 2`. |
181
+ | `"g"` | integer | `headers`+ | Group ID. Optional; only meaningful when `st == 1`. |
182
+
183
+ #### Why these four keys exist
184
+
185
+ A `SUBGROUP_HEADER` carries a track alias, a group, a subgroup and a publisher
186
+ priority. A fetch header carries a request ID. A datagram header carries a track
187
+ alias, a group, an object and a priority. Before this revision the model could
188
+ express group, object, priority and status and nothing else — **track alias,
189
+ subgroup ID and fetch request ID had nowhere to live**, and at `"headers"` there
190
+ are no payload bytes to re-parse them from. A `"headers"` recording therefore
191
+ could not answer which track a stream belonged to, which is most of what the
192
+ level exists for.
193
+
194
+ **A writer MUST write `"fri"` on a fetch stream** (`st == 2`), where it is the
195
+ only correlation between the stream and the FETCH that asked for it. A reader
196
+ MUST NOT reject a fetch stream that lacks it: recordings predating this revision
197
+ have none, and Event 1 is not a type a reader may discard on a missing optional
198
+ key. The same asymmetry as [`"msg"`](#msg-field-naming-and-shape) — writers
199
+ conform, readers tolerate.
200
+
201
+ The other three are written when known. `"sg"`, `"fri"` and `"g"` are each
202
+ scoped to one stream type because on the others they have no source; a writer
203
+ MUST NOT write one outside its scope, and a reader that meets one there MUST
204
+ **keep** it — read it into the field it names, and write it back — rather than
205
+ reject the event or route it elsewhere.
206
+
207
+ "Keep", not "ignore": a reader is free to disregard the *meaning* of an
208
+ out-of-scope key, but it does not get to drop the value. Dropping would violate
209
+ [the preservation rule](#versioning-and-compatibility), and routing it to the
210
+ unrecognised-key store would be wrong twice over — the key *is* recognised, its
211
+ value *is* usable, and a key held in both places is written back twice.
212
+
213
+ Wrong *place* is not wrong *shape*, and they part company here. A `"sg"` on a
214
+ fetch stream is a usable value in a position that means nothing: it reaches the
215
+ field. A `"sg"` carrying a text string is not a usable value at all, wherever it
216
+ sits, and goes to the unrecognised-key store under [the rule for unusable
217
+ types](#versioning-and-compatibility). A reader has to answer both questions,
218
+ in that order.
219
+
220
+ **Event 3 wins.** Where a value appears both here and on an Event 3 for the same
221
+ stream — a group ID, most obviously — **Event 3 is authoritative** and this copy
222
+ is a convenience for readers that have not yet seen an object. A reader MUST NOT
223
+ treat a disagreement as corruption; it MUST prefer Event 3.
224
+
225
+ `"g"` is scoped to datagrams for that reason: on a subgroup stream every object
226
+ carries the same group by construction, so a copy here would be a second field
227
+ with no independent source. `"pp"` is deliberately **not** added for the same
228
+ reason — Event 3's `"pp"` is the stream header's publisher priority copied per
229
+ object, and a second copy would have no tiebreak.
230
+
231
+ #### Integer keys decode to the same type as their Event 3 counterparts
232
+
233
+ `"ta"`, `"sg"`, `"fri"` and `"g"` MUST decode to whatever language type an
234
+ implementation gives Event 3's `"g"`, `"o"` and `"sid"` — not to whatever it
235
+ gives Event 1's existing `"st"` and `"d"`.
236
+
237
+ Those two groups differ today, for a good reason that does not extend here:
238
+ `"st"` and `"d"` are small enumerations, and an implementation may reasonably
239
+ hold them in a narrow type. The keys added above are wire identifiers with the
240
+ same range as Event 3's. An implementer following the enclosing event's local
241
+ convention would type Event 1's `"g"` as a JavaScript `number` while Event 3's
242
+ is a `bigint`, and `event1.g === event3.groupId` would then evaluate `42 === 42n`
243
+ as `false` — silently, and for every reader that made the same reasonable
244
+ choice.
245
+
246
+ ### Event 2: Stream Closed
151
247
 
152
248
  | Key | Type | Detail Level | Description |
153
249
  | ------- | ------- | ------------ | ---------------------------- |
154
250
  | `"sid"` | integer | `headers`+ | QUIC stream ID |
155
251
  | `"ec"` | integer | `headers`+ | Error code (0 = clean close) |
156
252
 
157
- #### Event 3: Object Header
253
+ ### Event 3: Object Header
158
254
 
159
255
  Object header events and object payload events (event 4) for the same object share `(sid, g, o)` as a composite key. An object header event is always emitted before its corresponding payload event.
160
256
 
@@ -166,37 +262,584 @@ Object header events and object payload events (event 4) for the same object sha
166
262
  | `"pp"` | integer | `headers`+ | Publisher priority |
167
263
  | `"os"` | integer | `headers`+ | Object status (0=normal, 1=end-of-group, 2=end-of-track, 3=does-not-exist) |
168
264
 
169
- #### Event 4: Object Payload
265
+ ### Event 4: Object Payload
266
+
267
+ **Payload-bearing event** — see [Privacy Considerations](#privacy-considerations).
170
268
 
171
- | Key | Type | Detail Level | Description |
172
- | ------- | ----------- | ---------------- | ----------------------------------- |
173
- | `"sid"` | integer | `headers+sizes`+ | Stream ID |
174
- | `"g"` | integer | `headers+sizes`+ | Group ID |
175
- | `"o"` | integer | `headers+sizes`+ | Object ID |
176
- | `"sz"` | integer | `headers+sizes`+ | Payload size in bytes |
177
- | `"pl"` | byte string | `headers+data`+ | Payload bytes (or zeroed if masked) |
269
+ | Key | Type | Detail Level | Description |
270
+ | ------- | ----------- | ---------------- | ---------------------------------------------------------- |
271
+ | `"sid"` | integer | `headers+sizes`+ | Stream ID |
272
+ | `"g"` | integer | `headers+sizes`+ | Group ID |
273
+ | `"o"` | integer | `headers+sizes`+ | Object ID |
274
+ | `"sz"` | integer | `headers+sizes`+ | Payload size in bytes |
275
+ | `"pl"` | byte string | `headers+data`+ | Payload bytes (or zeroed if masked). Payload-bearing. |
178
276
 
179
- #### Event 5: State Change
277
+ ### Event 5: State Change
180
278
 
181
279
  | Key | Type | Detail Level | Description |
182
280
  | -------- | ----------- | ------------ | ---------------------- |
183
281
  | `"from"` | text string | `control`+ | Previous session phase |
184
282
  | `"to"` | text string | `control`+ | New session phase |
185
283
 
186
- #### Event 6: Error
187
-
188
- | Key | Type | Detail Level | Description |
189
- | ---------- | ----------- | ------------ | --------------------- |
190
- | `"ec"` | integer | `control`+ | Error code |
191
- | `"reason"` | text string | `control`+ | Human-readable reason |
192
-
193
- #### Event 7: Annotation
284
+ ### Event 6: Error
285
+
286
+ | Key | Type | Detail Level | Description |
287
+ | ---------- | ---------------- | ------------ | --------------------- |
288
+ | `"ec"` | integer | `control`+ | Error code |
289
+ | `"reason"` | text string | `control`+ | Human-readable reason |
290
+ | `"sid"` | unsigned integer | `control`+ | QUIC stream ID the error was observed on. **Optional**, on the same terms as [Event 0's `"sid"`](#event-0-control-message): absent means there was no stream or none is known, and readers MUST distinguish that from stream `0`. |
291
+ | `"ek"` | text string | `control`+ | Error kind. **Optional.** See below. |
292
+ | `"rawlen"` | unsigned integer | `headers+sizes`+ | Byte length of the input the recorder held for this error, before any truncation. **Optional.** See below. |
293
+ | `"raw"` | byte string | `full` only | The offending bytes, truncated to the cap below. **Optional.** Payload-bearing — see [Privacy Considerations](#privacy-considerations). |
294
+
295
+ #### Why an error event carries bytes
296
+
297
+ A peer that sends something malformed is one of the few things a shared trace
298
+ is uniquely good for: the recording party can see it and the sending party
299
+ cannot. A report saying "your SUBSCRIBE_OK did not parse" is an assertion; the
300
+ same report carrying the bytes that did not parse is evidence, and the other
301
+ party can run it against their own encoder without reproducing the session.
302
+
303
+ Before these keys the only field able to hold bytes was Event 0's `"raw"`, and
304
+ a recorder wanting to keep the evidence had to claim the violation was a
305
+ control message in order to have somewhere to put it. That is a worse record
306
+ than none: it asserts a decodable message where there was a protocol
307
+ violation, and no reader can tell the two apart. One recorder in this project
308
+ declined the trade and left the note behind in its source — *"the offending
309
+ bytes stay behind: Event 6 has no field to carry them"* — which is the gap
310
+ these keys close.
311
+
312
+ #### `"ek"` — error kind
313
+
314
+ An open vocabulary. This revision names `"protocol"` (the peer violated the
315
+ protocol), `"transport"` (the QUIC or WebTransport layer failed), and
316
+ `"decode"` (bytes that would not parse as any message this recorder knows).
317
+ Other values MAY be used and MAY be added without a version bump; readers MUST
318
+ preserve a value they do not recognise and MUST NOT reject the event, as with
319
+ [`"perspective"`](#perspective).
320
+
321
+ `"ek"` is spelled in full rather than as `"kind"` because `"kind"` is already a
322
+ top-level key on [Event 10](#event-10-subscription-derivation) with the
323
+ vocabulary `"created"` / `"shared"`. Two vocabularies under one key at the same
324
+ map level survive in a typed reader and break in every flat projection of a
325
+ trace — a JSONL export, a warehouse load, a column-oriented viewer — where
326
+ `kind` becomes one column holding both.
327
+
328
+ #### `"raw"` is capped, and `"rawlen"` says whether the cap bit
329
+
330
+ **A recorder MUST NOT write more than 4096 bytes in `"raw"`.** Where it holds
331
+ more, it writes the first 4096 and records the full length in `"rawlen"`.
332
+
333
+ The cap is a MUST and a fixed number rather than a SHOULD and a suggestion,
334
+ because Event 6 is [non-droppable](#non-droppable-event-types). Every other
335
+ high-volume field in this format is bounded by sampling; this one cannot be,
336
+ so if it is not bounded here it is not bounded at all. A peer opening ten
337
+ thousand streams and sending garbage on each is not a hypothetical — it is
338
+ what a fuzzer does to a relay, and a trace of it should not be larger than the
339
+ attack. A number a reader can test also means the corpus can hold a case
340
+ proving the cap was applied rather than merely described; "SHOULD keep it
341
+ small" cannot be tested by anyone.
342
+
343
+ 4096 was chosen against two known quantities. It is far above what control
344
+ messages actually measure: the largest encoded control message across every
345
+ draft in the shared codec vectors is 95 bytes, and each of the four real
346
+ third-party captures in the conformance corpus is under 3 kB *in total*,
347
+ control messages, object headers and all. And it sits well below
348
+ `MAX_MESSAGE_LENGTH`, the 65535-byte ceiling the protocol itself puts on a
349
+ control message from draft-11 onward — deliberately below, because `"raw"`
350
+ exists for diagnosis and not for replay. A message can legitimately be larger
351
+ than the cap; the cap is not a claim that it cannot. A fault that leaves no trace in the
352
+ first 4096 bytes is a real possibility, and it is what `"rawlen"` is for: the
353
+ reader learns that the record is partial and how much is missing, which is
354
+ enough to go and ask for the rest.
355
+
356
+ **`"rawlen"` sits at `headers+sizes`, not at `full` and not at `control`.** It
357
+ is available in every trace where `"raw"` MUST NOT appear, which is the point
358
+ of having it: how large a malformed message was is often enough on its own to
359
+ separate a truncated message from a mistyped one, and it carries no content.
360
+
361
+ It is nonetheless a **size**, and this format gates sizes deliberately —
362
+ `headers+sizes` exists precisely to mean "how much, but not what". Putting
363
+ `"rawlen"` at `control` would let a `control`-level trace carry a fact about
364
+ data-stream volume that its own declared level excludes, because Event 6's
365
+ `"sid"` may name a data stream: the length would then be the size of a run of
366
+ media framing. One number is a small leak, but the latch below bounds `"raw"`
367
+ and not `"rawlen"`, so on a repeatedly failing stream it is a *sequence* of
368
+ sizes, which is the shape traffic analysis wants. Gating it with the other
369
+ sizes costs a `control`-level trace one diagnostic and keeps each level's
370
+ promise exactly what it says.
371
+
372
+ A recorder at `headers+sizes` or above SHOULD write it whether or not it is
373
+ also writing the bytes.
374
+
375
+ When `"raw"` is present, `"rawlen"` SHOULD be present too, and a reader MAY
376
+ take `rawlen > len(raw)` as the definition of a truncated capture. If
377
+ `"rawlen"` is absent, a `"raw"` of exactly 4096 bytes MUST be treated as
378
+ possibly truncated, since that is the one length the cap makes ambiguous.
379
+
380
+ **A recorder that does not know the true length MUST omit `"rawlen"` rather
381
+ than write the length it happens to hold.** This is the case the key is most
382
+ easily got wrong in, and it defeats the key entirely. A recorder that stops
383
+ *reading* at the cap — which is the sensible thing to do with a stream that
384
+ will not parse, and what the recorder cited below does — never holds more than
385
+ 4096 bytes. Writing `rawlen` as 4096 there says `rawlen == len(raw)`, which is
386
+ this section's own signal for **not truncated**: the field would assert
387
+ completeness precisely when the recorder has the least idea whether the input
388
+ was complete. Omitting it falls through to the rule above, where a `"raw"` of
389
+ exactly the cap is treated as possibly truncated, which is the truth.
390
+
391
+ Where the length *is* knowable without keeping the bytes, write it. A
392
+ length-prefixed control message supplies it from its own length field, and a
393
+ recorder that read that prefix and then gave up on the body knows exactly how
394
+ much it did not keep. That is the case `"rawlen"` exists for, and it is common:
395
+ the fault is often the body, and the prefix is what got the recorder as far as
396
+ noticing.
397
+
398
+ **The cap binds the recorder, and nothing else.** It is addressed to the party
399
+ deciding what to say about traffic it has just observed. It is *not* addressed
400
+ to a tool that reads an event and serializes it again: a reader meeting a
401
+ `"raw"` longer than 4096 bytes MUST NOT reject the event, and a rewrite MUST
402
+ NOT shorten it. That is [the reader rule outranking the writer
403
+ rule](#versioning-and-compatibility), and this is the case it exists for.
404
+ Re-truncating someone else's file destroys evidence in order to make it conform
405
+ to a rule that was never addressed to the tool doing the truncating. Report the
406
+ non-conformance if it is worth reporting; do not repair it.
407
+
408
+ The distinction has a consequence worth stating for implementers, because
409
+ getting it wrong is easy and the result is silent. **A serializer is the wrong
410
+ place to enforce this cap.** A serializer cannot tell a freshly recorded event
411
+ from one that arrived by being read, so a cap applied there either truncates
412
+ evidence on rewrite or rejects a file the reader was required to accept —
413
+ whichever it does, it does to the wrong events. The cap belongs where the event
414
+ is *constructed from observed bytes*.
415
+
416
+ #### One `"raw"` per flow
417
+
418
+ **A recorder MUST NOT emit `"raw"` more than once per flow** — per stream where
419
+ the error names one, per peer where it does not, and once for the whole
420
+ recording where it writes no peer identifier either. That last case is every
421
+ recorder but a relay tap, since `"p"` is only required of one: the three
422
+ collapse to "the narrowest scope the recorder can name", and a recorder that
423
+ can name none has one flow. Later errors on a flow that
424
+ has already carried its bytes are still recorded; they simply carry no `"raw"`.
425
+
426
+ The latch is on the field, not on the event, and the distinction is the whole
427
+ point. Event 6 is non-droppable: suppressing the *event* would discard the
428
+ causal record this document elsewhere forbids discarding, and would do it
429
+ precisely when a peer is misbehaving repeatedly, which is when the record
430
+ matters most. What actually grows without bound is the bytes, so that is what
431
+ is bounded. The first failure on a flow is the diagnostic one in any case —
432
+ once a stream will not parse, a recorder is no longer interpreting what
433
+ follows, so the "bytes" of the second error are the same undifferentiated run
434
+ as the first.
435
+
436
+ This matches what a recorder in this project already does, one level up: its
437
+ control-stream reader latches on the first undecodable run and stops reading
438
+ that stream, capping the kept bytes at 4096. This section takes that behaviour,
439
+ which was chosen for a panel display, and makes it the format's rule.
440
+
441
+ ### Event 7: Annotation
194
442
 
195
443
  | Key | Type | Detail Level | Description |
196
444
  | --------- | ----------- | ------------ | --------------------------------- |
197
445
  | `"label"` | text string | any | User-defined label |
198
446
  | `"data"` | any | any | User-defined data (any CBOR type) |
199
447
 
448
+ ### Event 8: Peer Connected
449
+
450
+ Emitted by `"relay-tap"` recorders when a new peer session is established through the relay.
451
+
452
+ | Key | Type | Detail Level | Description |
453
+ | -------------- | ----------- | ------------ | ------------------------------------------------------------------------------------ |
454
+ | `"p"` | text string | `control`+ | Peer identifier (source-local); subsequent events from this peer carry the same `"p"`. |
455
+ | `"endpoint"` | text string | `control`+ | Peer-reported endpoint URI or remote address (best-effort). Optional. |
456
+ | `"transport"` | text string | `control`+ | Transport type (`"webtransport"`, `"raw-quic"`, etc.). Optional. |
457
+ | `"role"` | text string | `control`+ | `"publisher"`, `"subscriber"`, or `"both"` if known at connection time; else absent. |
458
+ | `"side"` | text string | `control`+ | `"downstream"` if the peer connected *to* this relay; `"upstream"` if this relay connected *to* the peer (origin or upstream relay). Absent for non-relay perspectives. Identifies the connection direction, not subscription causality — see Event 10 for subscription chains. |
459
+
460
+ ### Event 9: Peer Disconnected
461
+
462
+ | Key | Type | Detail Level | Description |
463
+ | ---------- | ----------- | ------------ | -------------------------------------------------------- |
464
+ | `"p"` | text string | `control`+ | Peer identifier (matches the corresponding event 8). |
465
+ | `"ec"` | integer | `control`+ | Error/close code (0 = clean close). |
466
+ | `"reason"` | text string | `control`+ | Human-readable reason. Optional. |
467
+
468
+ ### Event 10: Subscription Derivation
469
+
470
+ Emitted by `"relay-tap"` recorders when an upstream subscription is causally created or extended to satisfy one or more downstream subscriptions. This is the structural primitive for multi-hop correlation: the collector reconstructs end-to-end trace trees from these derivation links plus `"traceId"` propagation (see [Trace ID Propagation](#trace-id-propagation)).
471
+
472
+ The same event is emitted again when an additional downstream subscription is satisfied by an existing upstream subscription (subscription fan-in over time) — the `"d"` array reflects all currently linked downstream subs at emission time.
473
+
474
+ | Key | Type | Detail Level | Description |
475
+ | ----------- | --------------------- | ------------ | --------------------------------------------------------------------------------------- |
476
+ | `"u"` | array `[text, uint]` | `control`+ | `[upstream-peer-id, upstream-request-id]` — the upstream subscription. Peer IDs are source-local. |
477
+ | `"d"` | array of arrays | `control`+ | List of `[downstream-peer-id, downstream-request-id]` pairs causing/sharing this upstream sub. |
478
+ | `"kind"` | text string | `control`+ | `"created"` (new upstream sub) or `"shared"` (existing upstream sub now serving an additional downstream sub). |
479
+ | `"traceId"` | byte string | `control`+ | Optional. 16-byte trace ID propagated via the `MOQTAP_TRACE_ID` extension parameter (see [Trace ID Propagation](#trace-id-propagation)). |
480
+ | `"ns"` | array of byte strings | `control`+ | Optional. Track namespace the subscription targets, one byte string per namespace field. |
481
+ | `"tn"` | byte string | `control`+ | Optional. Track name the subscription targets. |
482
+ | `"tdr"` | integer | `control`+ | Optional. Timestamp at which the downstream SUBSCRIBE was received. |
483
+ | `"tus"` | integer | `control`+ | Optional. Timestamp at which the upstream SUBSCRIBE was transmitted. Absent for terminal subscriptions, where the relay is the content origin. |
484
+ | `"tuo"` | integer | `control`+ | Optional. Timestamp at which SUBSCRIBE_OK was received from upstream. Absent for terminal or still-in-flight subscriptions. |
485
+ | `"tdo"` | integer | `control`+ | Optional. Timestamp at which SUBSCRIBE_OK was transmitted downstream. Absent for still-in-flight subscriptions. |
486
+
487
+ **Timestamps.** `"tdr"`, `"tus"`, `"tuo"` and `"tdo"` use the same timebase as the common `"t"` field — microseconds since the containing segment's `startTime`, on the emitting source's clock. Differences between them are therefore meaningful without any cross-hop clock agreement, which is the point: they measure how long *this* relay took, not when two relays each think it was. A consumer MUST NOT subtract a timestamp produced by one source from one produced by another.
488
+
489
+ **Emission and update.** A source SHOULD emit this event as soon as the downstream SUBSCRIBE is received, carrying whichever timestamps are known at that moment, and MAY emit further events for the same `(u, d)` pair as later timestamps become available. A consumer MUST treat a later event for the same pair as an update, overwriting earlier field values, rather than recording a second derivation.
490
+
491
+ **Track identity is optional but recommended.** `"ns"` and `"tn"` are not required — the pair can in principle be recovered by correlating request IDs against the SUBSCRIBE control-message events. Including them makes the event self-contained, which matters when the trace is filtered, sampled at the control-message level, or joined across sources that do not share a control-message stream.
492
+
493
+ ## Sampling
494
+
495
+ When the source dropped, sampled, or filtered events before writing them, the header SHOULD include a `"sampling"` map so consumers know they are seeing a partial view.
496
+
497
+ | Key | CBOR Type | Required | Description |
498
+ | ------------------ | ---------------- | -------- | -------------------------------------------------------------------------------------------------------- |
499
+ | `"effectiveRate"` | float | No | Effective fraction of source events retained, in `(0.0, 1.0]`. `1.0` means no rate-based dropping. |
500
+ | `"maxEventsPerSec"`| unsigned integer | No | Per-segment cap that triggered drops, if rate-limited. |
501
+ | `"dropPolicy"` | text string | No | Drop strategy when the cap was exceeded: `"head"`, `"tail"`, or `"sampled"`. |
502
+ | `"droppedTotal"` | unsigned integer | No | Cumulative events dropped since `startTime`. In segmented traces, this is the running total at segment start. |
503
+ | `"droppedSegment"` | unsigned integer | No | Events dropped within this segment only. Only meaningful when `"segment"` is present. |
504
+ | `"rule"` | text string | No | Source-side filter rule that selected events (e.g., `"namespace prefix=foo/bar"`). |
505
+ | `"ruleLang"` | text string | No | Filter language identifier: `"prefix"`, `"suffix"`, `"glob"`, `"cel"`, etc. |
506
+ | `"appliesTo"` | array of int | When sampling is active | Event type IDs to which the drop policy was applied. |
507
+
508
+ A reader that sees a `"sampling"` map SHOULD surface the fact that the trace is partial. A trace without `"sampling"` is presumed complete relative to the declared detail level.
509
+
510
+ ### Non-droppable Event Types
511
+
512
+ Some event types carry causal or structural information that is destroyed by random sampling — a SUBSCRIBE without its SUBSCRIBE_OK, a state change with no following events, a peer-disconnect with no matching peer-connect — yielding a misleading trace. Sources MUST NOT drop or sample the following event types under any drop policy:
513
+
514
+ - Event `0` — Control message
515
+ - Event `5` — State change
516
+ - Event `6` — Error
517
+ - Event `8` — Peer connected
518
+ - Event `9` — Peer disconnected
519
+ - Event `10` — Subscription derivation
520
+
521
+ If a source cannot keep up with the rate of these event types, it MUST refuse the recording or subscription rather than emit a sampled trace. Source-side **filtering** (e.g., dropping all events for namespace `foo/bar`) is not sampling and remains permitted — filtered traces are still causally complete with respect to what passed the filter.
522
+
523
+ Sampling MAY apply to the high-volume event types: `1` (Stream opened), `2` (Stream closed), `3` (Object header), `4` (Object payload), and `7` (Annotation). When sampling is applied, `"appliesTo"` MUST list the affected event types; readers MAY then assume non-listed event types are complete.
524
+
525
+ ## Trace ID Propagation
526
+
527
+ For end-to-end correlation across multiple relays and clients, moqtap defines a `MOQTAP_TRACE_ID` extension parameter (see the moqtap draft for the assigned parameter ID) carried on SUBSCRIBE messages. The trace ID is a **16-byte opaque identifier** that:
528
+
529
+ - Is generated by the originating endpoint (publisher client, subscriber client, or first moqtap-aware relay if no upstream party set one).
530
+ - MUST be propagated unchanged onto upstream SUBSCRIBEs that a relay creates to satisfy a downstream SUBSCRIBE carrying it.
531
+ - SHOULD appear as the `"traceId"` field on Event 10 (Subscription Derivation) and MAY appear on any other event whose triggering subscription carries one.
532
+ - Allows a moqtap collector to stitch traces from multiple sources (clients pushing moqtraces, relays exposing tap tracks) into a unified end-to-end view, with no topology configuration required.
533
+
534
+ **The trace ID is carried as raw bytes at every layer** — a 16-byte parameter value on the wire, a 16-byte CBOR byte string in `"traceId"`. It is never hex-encoded, base64-encoded, or otherwise transformed. This is not a stylistic preference: stitching only works if two independently written implementations produce byte-identical values for the same subscription chain, and every encoding step is a place for them to disagree. A source holding a trace ID whose length is not exactly 16 bytes MUST treat it as malformed rather than pad, truncate, or re-encode it.
535
+
536
+ Privacy: trace IDs reveal subscription patterns across operator boundaries. Relay operators MAY hash, replace, or strip the trace ID at egress per their policy. The moqtap draft documents the recommended defaults.
537
+
538
+ ## Privacy Considerations
539
+
540
+ Trace recording ranges from protocol-metadata-only (low sensitivity) to full media capture (very high sensitivity). Implementers and operators MUST treat the following as elevated-risk capabilities:
541
+
542
+ 1. **Payload-bearing events and fields.** These carry or reveal user content:
543
+ - Event 4 `"pl"` (object payload bytes, `"headers+data"`+)
544
+ - Event 0 `"raw"` (raw control-message bytes, `"full"`)
545
+ - [Event 6](#event-6-error) `"raw"` (the bytes behind an error, `"full"`).
546
+ Easy to overlook, because an error event sounds like metadata: its
547
+ `"sid"` may name a *data* stream, in which case the bytes behind the
548
+ error are subgroup framing and object payload — media, arriving through
549
+ the one event type sampling may never drop.
550
+ - Detail levels `"headers+data"` and `"full"` in aggregate
551
+
552
+ Capturing them is **optional** for all conformant recorders. Observer- and tap-based capture of these fields is equivalent to intercepting user media; recorders MUST NOT enable it by default and SHOULD require explicit operator opt-in per session or per track. The default detail level for general-purpose tools SHOULD be `"control"` or `"headers"`.
553
+
554
+ 2. **Payload size leakage.** Even without payload bytes, `"headers+sizes"` reveals object sizes, which can fingerprint media content. [Event 6](#event-6-error)'s `"rawlen"` belongs to this class rather than to the one above — it is gated with the sizes for that reason — and it leaks a little differently: the one-`"raw"`-per-flow latch does not bind it, so a stream that fails repeatedly yields a *sequence* of sizes rather than a single one. Operators handling sensitive traffic SHOULD evaluate size leakage as part of their threat model.
555
+
556
+ 3. **Trace ID correlation.** `MOQTAP_TRACE_ID` enables cross-operator correlation of subscription behavior. See [Trace ID Propagation](#trace-id-propagation) for egress mitigations.
557
+
558
+ 4. **Peer identifiers.** The `"p"` field is source-local but may encode address- or user-identifying information if the source chooses to. Publishers of traces SHOULD redact or opaquely hash `"p"` before sharing traces outside the recording operator's trust boundary.
559
+
560
+ 5. **Passive (observer / relay-tap) capture.** Perspectives `"observer"` and `"relay-tap"` record traffic that the recording party is not a direct participant in. Operators MUST ensure they have authorization to record such traffic under applicable law and session-setup consent; this specification does not grant such authorization.
561
+
562
+ Retention, access control, and disclosure of `.moqtrace` files are out of scope here but are expected to be governed by the deploying operator's policy.
563
+
564
+ ---
565
+
566
+ # Part 2: `.moqtrace` Binary File Format
567
+
568
+ A `.moqtrace` file serializes the event model above to a self-describing CBOR container.
569
+
570
+ ## File Layout
571
+
572
+ ```
573
+ Offset Length Content
574
+ ------ ------ -------
575
+ 0 8 Magic bytes: "MOQTRACE" (0x4d4f515452414345)
576
+ 8 4 Format version (uint32 LE) — currently 2
577
+ 12 4 Header length (uint32 LE) — byte length of the CBOR header blob
578
+ 16 N Header (CBOR map) — session metadata and recording configuration
579
+ 16+N ... Event stream (CBOR sequence) — concatenated CBOR items, one per event
580
+ ```
581
+
582
+ A **segmented** trace is a concatenation of one or more such layouts back-to-back; each segment begins with magic bytes and is independently parseable. See [Segmented Traces](#segmented-traces).
583
+
584
+ ### Magic Bytes
585
+
586
+ The 8-byte ASCII string `MOQTRACE` identifies the file format. Any tool can check these bytes before attempting to parse. In segmented traces, the same bytes also serve as a segment delimiter — readers can resync to the next segment by scanning for the magic.
587
+
588
+ ### Format Version
589
+
590
+ uint32 little-endian. Currently `2`. See [Versioning and Compatibility](#versioning-and-compatibility) for which versions a reader must accept.
591
+
592
+ ### Header Length
593
+
594
+ uint32 little-endian. The byte length of the CBOR-encoded header that immediately follows. This allows readers to extract metadata without scanning the event stream — useful for file browsers, search indexing, and quick filtering ("show me all draft-17 client traces").
595
+
596
+ ## Header
597
+
598
+ A single CBOR map with the following keys:
599
+
600
+ | Key | CBOR Type | Required | Description |
601
+ | --------------- | ----------- | -------- | -------------------------------------------------------------------------------------------------------- |
602
+ | `"protocol"` | text string | Yes | MoQT version identifier (e.g., `"moq-transport-17"`, `"moq-transport-rfc9999"`) |
603
+ | `"perspective"` | text string | Yes | Recording viewpoint: `"client"`, `"server"`, `"observer"`, or `"relay-tap"` |
604
+ | `"detail"` | text string | Yes | Detail level (see [Detail Levels](#detail-levels)) |
605
+ | `"startTime"` | unsigned integer | Yes | Recording start time (Unix epoch milliseconds). Unsigned: a trace from before 1970 is a clock fault, not a recording, and a reader that accepts one has no way to say so. |
606
+ | `"transport"` | text string | No | Transport type (e.g., `"webtransport"`, `"raw-quic"`) |
607
+ | `"endTime"` | unsigned integer | No | Recording end time (Unix epoch milliseconds). Written when trace is finalized. |
608
+ | `"source"` | text string | No | Software that produced the trace (e.g., `"moqtap-devtools/0.1.0"`, `"my-relay/2.3.1"`). Also serves as the namespace for source-local `"p"` values. |
609
+ | `"endpoint"` | text string | No | Remote peer URI (e.g., `"https://relay.example.com/moq"`) |
610
+ | `"sessionId"` | text string | No | Capture-correlation identifier — see [Identifier Scoping](#identifier-scoping). |
611
+ | `"segment"` | map | No | Segment metadata; present iff this trace is one segment of a segmented stream. See [Segmented Traces](#segmented-traces). |
612
+ | `"sampling"` | map | No | Sampling/filter metadata; present iff events were sampled or filtered at the source. See [Sampling](#sampling). |
613
+ | `"custom"` | map | No | User-defined metadata (arbitrary key-value pairs). `"payloadMasked": true` here indicates payload masking is active. |
614
+
615
+ ### Unrecognised keys in the header
616
+
617
+ [Versioning and Compatibility](#versioning-and-compatibility) requires a reader
618
+ that writes a trace back out to preserve the unrecognised keys it read, in
619
+ "header maps or event maps". This section says what that means for the header,
620
+ because the header is where the rule was least obviously binding and where both
621
+ reference implementations broke it: each read a header, dropped every key it
622
+ did not know, and wrote out a file that looked as though it had never carried
623
+ them.
624
+
625
+ **Three maps in the header have keys this document names**: the header map
626
+ itself, `"segment"`, and `"sampling"`. Each MUST carry its own
627
+ unrecognised-key store, and each store MUST be written back into the map it
628
+ came from. One store for the whole header would not do — a private key on
629
+ `"segment"` and a key of the same name at the top level are different keys, and
630
+ re-emitting either in the other's map changes what the file says.
631
+
632
+ Everything the [key-preservation rules](#versioning-and-compatibility) say
633
+ about events applies here unchanged, reading "header" for "event" and "the
634
+ segment" for "the event":
635
+
636
+ - A key this document does not define goes to the store of the map it appeared
637
+ in, and is written back from there.
638
+ - A key this document *does* define, carrying a value the reader cannot use,
639
+ goes to that same store. Knowing more about a key must not mean preserving it
640
+ less. `"transport": 42` is not a transport; it is also not nothing.
641
+ - Which values are usable is settled by [the same
642
+ list](#versioning-and-compatibility) — integral floats yes, fractional no,
643
+ negative no for a key defined as unsigned, and no rounding of a value the
644
+ reader cannot represent.
645
+ - **The range clause binds only where this document states a range.** In the
646
+ header that is one key: `"effectiveRate"`, defined as lying in `(0.0, 1.0]`.
647
+ A rate of `1.5`, of `0.0`, or of NaN is unusable and goes to the sampling
648
+ store. This is a semantic check rather than a type check and it is the only
649
+ one in the header, deliberately — every other key here is bounded by its type
650
+ alone, and a reader inventing bounds this document does not state would start
651
+ rejecting values a future revision means to allow. The reason `"effectiveRate"`
652
+ earns the check is that consumers divide by it: a rate of `0.0` handed to a
653
+ caller reconstructing true event counts is a division by zero, and `1.5` is a
654
+ count larger than what was recorded.
655
+ - **A value under a CBOR tag is unusable**, tag 64 excepted. This format uses
656
+ no tags; a writer MUST NOT emit one. Tag 64 is the exception readers already
657
+ MUST accept for byte strings (see [Interoperability](#interoperability)), and
658
+ a reader that unwraps it and re-emits the bytes as major type 2 has done what
659
+ that rule asks — so "written back unchanged" binds the value, not its
660
+ encoding. Any other tag is a shape this document gives no meaning, and it
661
+ goes to the store with its tag intact where the reader can hold one.
662
+
663
+ #### A composite value is usable only as a whole
664
+
665
+ `"sampling.appliesTo"` is an array of event type IDs. If one element is not
666
+ one, the array is unusable and goes to the store **entire**. A reader MUST NOT
667
+ keep the elements it liked and discard the rest.
668
+
669
+ This is the shape where discarding is not merely lossy but wrong. An
670
+ `"appliesTo"` of `[3, "x", 5]` read as `[3, 5]` is not a partial answer: the
671
+ key names the event types the drop policy touched, and every type absent from
672
+ it may be treated as complete. Shortening the array therefore reports a sampled
673
+ event type as fully recorded — the opposite of what the file said, stated with
674
+ the same confidence. The same reasoning governs `"ns"` on Event 10, and any
675
+ array or map a future key defines.
676
+
677
+ #### `"custom"`, and values a reader cannot hold
678
+
679
+ `"custom"` is not one of the three maps above: every key in it belongs to
680
+ whoever wrote the trace, so there is no such thing as an unrecognised key
681
+ there and it needs no store. It is a passthrough. A reader MUST hand back what
682
+ the file carried, key for key and value for value, and MUST write back what it
683
+ was handed.
684
+
685
+ A reader whose representation cannot hold that map exactly MUST treat
686
+ `"custom"` as unusable and route the whole value to the *header's* store,
687
+ rather than hand back a version of it that lost something. Both reference
688
+ implementations declare `"custom"` as a string-keyed map, so for them a
689
+ `"custom"` that is not a map at all is unusable — and so is one carrying a
690
+ non-text key, *where the reader can see that it does*; the next subsection
691
+ explains why that qualifier is not a loophole. Losing typed access is the
692
+ smaller harm: nothing in this document gives `"custom"` keys meaning, so there
693
+ is nothing to lose but convenience, and the bytes survive.
694
+
695
+ A reader whose decoder happens to hand back a shape its own declared type
696
+ cannot describe — a map object where a string-keyed record was promised — is
697
+ not thereby conformant. It is preserving the bytes by accident, through a type
698
+ that lies to every caller reading the field, and the first caller to iterate
699
+ the keys finds a shape it was told could not occur. Route the value to the
700
+ store and keep the field's type true.
701
+
702
+ That generalises to a test worth applying to every key in this section:
703
+ **can this reader write back exactly what it read?** Where it can, the value
704
+ belongs in the field. Where it cannot, the value belongs in the store. A
705
+ reader that answers "no" and keeps the field anyway is the defect this section
706
+ exists to name.
707
+
708
+ #### Keys in these maps are text
709
+
710
+ Every key this document defines — in the header map, in `"segment"`, in
711
+ `"sampling"`, and by the nature of the field in `"custom"` — is a text string,
712
+ and **a writer MUST NOT emit a key of any other kind.**
713
+
714
+ What a reader does with one is deliberately left undefined, and nothing may
715
+ depend on two readers agreeing about it. The reason is worth stating, because
716
+ the obvious rule is one that cannot be implemented: **some CBOR decoders
717
+ cannot tell.** A decoder that returns maps as language-native objects coerces
718
+ the integer key `1` into the text key `"1"` before any code written against
719
+ this document runs, at which point it is indistinguishable from a text `"1"`
720
+ the same map might also carry — and the two collide. One of the two reference
721
+ implementations decodes exactly this way. Requiring it to report the
722
+ difference would be requiring something it cannot observe, and a specification
723
+ that mandates the impossible teaches implementers which of its rules to skip.
724
+
725
+ This is one instance of a general problem, not a special case: see [Shapes a
726
+ CBOR library may normalise before you see them](#shapes-a-cbor-library-may-normalise-before-you-see-them)
727
+ for the full list and the rule that governs all of it. A non-text key is the
728
+ most obvious member; the text key `"__proto__"`, a duplicate key, and CBOR
729
+ `undefined` are others, and each is invisible to at least one of the two
730
+ reference implementations.
731
+
732
+ So the requirement lands on the writer, where it can be met. What a reader
733
+ MUST NOT do is **re-key** — turn an integer key into a text key and write that
734
+ back — because the result is a file asserting a key the original never carried,
735
+ possibly colliding with a real one. A reader that can hold the key as it found
736
+ it SHOULD do exactly that and preserve it in the store; a reader whose decoder
737
+ has already re-keyed it before it was asked is not thereby non-conformant,
738
+ having never had the choice.
739
+
740
+ Rejecting the header is *not* the recommended answer here, which is worth
741
+ saying because an earlier draft of this section recommended it. Failing the
742
+ header loses every event behind it, and this subsection would then be
743
+ prescribing, for the most capable reader, the most destructive outcome — the
744
+ same harm the `"segment"`/`"sampling"` subsection below forbids for a value it
745
+ merely cannot use. Preserve it and move on. This is the one place in this
746
+ section where the two implementations are permitted to differ, and it is
747
+ permitted only because no conformant file reaches it.
748
+
749
+ #### `"segment"` and `"sampling"` are optional keys
750
+
751
+ A `"segment"` or `"sampling"` that is not a map MUST NOT fail the file. It goes
752
+ to the store like any other unusable optional value. A reader that rejects the
753
+ file instead has turned one unreadable metadata value into the loss of every
754
+ event behind it.
755
+
756
+ **But an unusable `"segment"` is not an absent one.** [Segmented
757
+ Traces](#segmented-traces) requires a reader to treat an absent `"segment"` as
758
+ "this is a complete, single-segment file", and that assertion MUST NOT be made
759
+ here: the file said it was a segment and the reader merely could not place it.
760
+ Making it would be the same invention as defaulting `"sequence"` to `0`, and
761
+ worse in a segmented stream, where a segment read as non-segmented has its
762
+ `"n"` and `"t"` taken as file-global when they are segment-local, silently
763
+ misordering every event in it.
764
+
765
+ No new field is needed to tell the two apart: the unusable value is sitting in
766
+ the header's store under the key `"segment"`, so a reader that finds it there
767
+ knows exactly which case it is in. What it MUST NOT do is answer "complete
768
+ single-segment file" to a caller that asks.
769
+
770
+ `"segment.sequence"` is the exception, and the only key in the header whose
771
+ absence or unusability makes the header itself malformed. It is the sole
772
+ ordering key of a segmented stream: a reader that cannot read it cannot place
773
+ the segment, and a default invents an order the file never had. The four
774
+ required top-level keys — `"protocol"`, `"perspective"`, `"detail"`,
775
+ `"startTime"` — are malformed on the same terms, there being no header to
776
+ construct without them.
777
+
778
+ Malformed here means malformed *for that segment*. A reader MUST report it and
779
+ MUST NOT present the segment as read; it MAY continue to the next segment, on
780
+ the same terms as [a malformed event](#versioning-and-compatibility). What it
781
+ MUST NOT do is return a header it invented — a required key filled in with a
782
+ default, an empty string, or a language's null standing in for a value the file
783
+ never carried. That reader reports a fabricated trace as a real one, which is
784
+ worse than reporting nothing.
785
+
786
+ #### The store is written last, and never twice
787
+
788
+ A store entry whose key the surrounding map already writes MUST NOT be written
789
+ a second time. A reader never produces such an entry — a key it recognised and
790
+ used is by definition not unrecognised — but a caller assembling a header by
791
+ hand can, and a CBOR map carrying one key twice is a map no two readers need
792
+ agree on. The field wins and the store entry is dropped. Event serialization
793
+ already resolves the collision this way.
794
+
795
+ ### Protocol Identifier
796
+
797
+ The `"protocol"` field identifies the MoQT version without assuming lifecycle stage. Use the IETF document name minus the `draft-ietf-` prefix for drafts, and the RFC number for published standards:
798
+
799
+ - Draft phase: `"moq-transport-07"`, `"moq-transport-17"`
800
+ - RFC phase: `"moq-transport-rfc9999"`
801
+
802
+ ### Perspective
803
+
804
+ The `"perspective"` field describes the vantage point of the recording:
805
+
806
+ - `"client"` — captured at the MoQT client (initiator of the QUIC connection)
807
+ - `"server"` — captured at the MoQT server or relay (single-session view from inside the endpoint)
808
+ - `"observer"` — passive capture (e.g., DevTools extension, network tap) that intercepts traffic without participating in the session
809
+ - `"relay-tap"` — active capture from inside a relay, reporting on **multiple** concurrent peer sessions through that relay. Distinct from `"server"` because events span peers; see the `"p"` field on events.
810
+
811
+ A reader that encounters a perspective value it does not recognize MUST NOT reject the trace. New values may be added without a version bump, and every event in the file remains parseable regardless. Preserve the value verbatim and surface it to the caller.
812
+
813
+ ## Event Stream
814
+
815
+ After the header, the remainder of the file (or segment) is a sequence of concatenated CBOR items (one per event). This is a standard CBOR sequence (RFC 8742) — no array wrapper, no separators. Event contents are defined in [Part 1](#part-1-event-model-normative).
816
+
817
+ ## Segmented Traces
818
+
819
+ A **segmented trace** is a sequence of self-contained `.moqtrace` blobs concatenated back-to-back. Each segment is independently parseable and includes its own magic bytes, format version, header, and event sequence. This enables:
820
+
821
+ - **On-disk capture rotation.** A long-running recorder can rotate to a new segment every N seconds or M megabytes without finalizing the file; consumers process completed segments while the recorder writes the next.
822
+ - **Live carriage.** A transport that wants to carry a `.moqtrace` stream live uses segment boundaries as its cut points — see [Appendix A](#appendix-a-moqt-carriage-convention-informative).
823
+ - **Best-effort recovery.** If a segment is truncated or corrupt, readers can skip forward to the next magic-byte sequence and resume.
824
+
825
+ A trace is segmented iff its header contains a `"segment"` map:
826
+
827
+ | Key | CBOR Type | Required | Description |
828
+ | ------------------ | ---------------- | -------- | ---------------------------------------------------------------------------------------------------------- |
829
+ | `"sequence"` | unsigned integer | Yes | 0-based sequence number of this segment within the stream. Monotonically increasing, no gaps in a single source. |
830
+ | `"durationMs"` | unsigned integer | No | Nominal segment duration in milliseconds (hint to readers; actual duration may differ). |
831
+ | `"streamId"` | text string | No | Opaque identifier shared across all segments of the same logical stream. |
832
+ | `"continues"` | boolean | No | `true` if this segment continues a previous one with the same `streamId`. `false` (or absent) for the first segment. |
833
+
834
+ Within a single segmented stream:
835
+
836
+ - The `"protocol"`, `"perspective"`, `"detail"`, and `"sessionId"` fields MUST be identical across all segments.
837
+ - `"startTime"` in each segment header refers to that segment's start, NOT the stream's start.
838
+ - Event `"n"` (sequence number) and `"t"` (timestamp) are **segment-local** in segmented traces: they restart at 0 in each segment. Readers stitching segments together compute global ordering from `(segment.sequence, n)`.
839
+ - `"sampling.droppedTotal"` is cumulative across the stream; `"sampling.droppedSegment"` is local to the segment.
840
+
841
+ A non-segmented trace MUST NOT include a `"segment"` field. Readers MUST treat the absence of `"segment"` as "this is a complete, single-segment file."
842
+
200
843
  ## Reading a `.moqtrace` File
201
844
 
202
845
  ```
@@ -204,16 +847,24 @@ Object header events and object payload events (event 4) for the same object sha
204
847
  2. Read 4 bytes (uint32 LE) → format version. Reject if unsupported.
205
848
  3. Read 4 bytes (uint32 LE) → header length N.
206
849
  4. Read N bytes → CBOR-decode to get header map.
207
- 5. Read remaining bytes as a CBOR sequence:
208
- - Repeatedly CBOR-decode one item at a time until EOF.
209
- - Each item is one event (a CBOR map).
850
+ 5. Read CBOR items one at a time:
851
+ - If the next 8 bytes are the magic "MOQTRACE", a new segment begins —
852
+ loop back to step 2 (segmented trace).
853
+ - Otherwise, CBOR-decode one item → one event map.
854
+ - Stop at EOF.
210
855
  ```
211
856
 
857
+ A reader that does not implement segmented mode MAY treat the first occurrence of `MOQTRACE` after byte 0 as EOF for the current trace.
858
+
859
+ If the stream ends part-way through a CBOR item, the file was truncated — a crashed recorder, an interrupted transfer. Readers SHOULD report this distinctly from a clean end-of-file, and SHOULD return the events decoded up to that point rather than discarding them. A truncated trace is still evidence, and the events before the cut are exactly as valid as they were.
860
+
212
861
  ## Writing a `.moqtrace` File
213
862
 
863
+ ### Single-segment (file) writer
864
+
214
865
  ```
215
866
  1. Write magic bytes "MOQTRACE"
216
- 2. Write format version (uint32 LE, currently 1)
867
+ 2. Write format version (uint32 LE, currently 2)
217
868
  3. CBOR-encode the header map → get header bytes
218
869
  4. Write header length (uint32 LE)
219
870
  5. Write header bytes
@@ -225,18 +876,111 @@ Object header events and object payload events (event 4) for the same object sha
225
876
  - Or accept that endTime may be absent in crash-truncated files
226
877
  ```
227
878
 
879
+ ### Segmented writer (file rotation)
880
+
881
+ ```
882
+ 1. For each segment:
883
+ a. Construct the segment header (include "segment.sequence", increment per segment).
884
+ b. Emit magic + version + headerLen + header bytes.
885
+ c. Emit one or more event CBOR items.
886
+ d. Close the segment (file rotation, etc.).
887
+ 2. The next segment begins fresh at step 1a; do NOT carry over event "n" or "t".
888
+ ```
889
+
228
890
  ## Versioning and Compatibility
229
891
 
230
- - **Version 1** is defined by this document.
231
- - Readers MUST check the format version before parsing.
232
- - Unknown keys in header maps or event maps MUST be ignored (forward-compatible).
233
- - Optional keys MAY be added to an existing event type without a version bump —
234
- `"sid"` on event 0 was added this way. A reader must therefore treat any
235
- optional key as absent-by-default rather than assuming files of a given
236
- version all carry the same keys.
237
- - New event types (`"e"` values) MAY be added in future versions; readers SHOULD skip unknown event types.
892
+ - **Version 2** is defined by this document. **Version 1** is defined by the previous revision.
893
+ - **Writers SHOULD write version 2.**
894
+ - **Readers MUST accept both version 1 and version 2, and MUST reject any other version.** A version-1 file is exactly a non-segmented version-2 trace without the keys this revision adds, and every added key is optional. Accepting version 1 therefore costs a reader nothing and keeps every previously recorded capture readable.
895
+ - Unknown keys in header maps or event maps MUST be ignored (forward-compatible). For the header specifically, see [Unrecognised keys in the header](#unrecognised-keys-in-the-header) — the header has three maps with named keys, not one, and each keeps its own.
896
+ - **A reader that writes a trace back out MUST preserve the unrecognised keys it read**, on a recognised event type as much as on an unrecognised one, and in the header as much as in an event. "Ignore" above means read past, not discard. Skipping a key costs a reader nothing; dropping it costs every reader downstream, because the output is a valid file that looks like it never carried the key — and the tools that read and rewrite traces are exactly the ones a trace passes through on its way to someone else: a redaction pass, a filter, a re-segmentation, a download with annotations applied.
897
+ - Optional keys MAY be added to an existing event type without a version bump — `"sid"` on event 0 was added this way. A reader must therefore treat any optional key as absent-by-default rather than assuming files of a given version all carry the same keys.
898
+ - New event types (`"e"` values) MAY be added without a version bump; readers MUST skip unknown event types rather than failing. A reader that rejects an unknown `"e"` value turns every future addition into a breaking change.
899
+ - New `"perspective"` and `"detail"` values MAY likewise be added; see [Perspective](#perspective).
900
+ - **A defined key whose value has an unusable type is treated as unrecognised.**
901
+ If this document says `"ta"` is an integer and a file carries `"ta": "hello"`,
902
+ a reader cannot use the value — but it MUST NOT delete it. The key goes to the
903
+ unrecognised-key store, is ignored for meaning, and is written back unchanged,
904
+ exactly as a key the reader had never heard of would be. A reader that knows
905
+ more about a key must not therefore preserve it less. "Unchanged" binds the
906
+ value, not its position: such a key is written wherever the store is emitted,
907
+ which is generally after the keys the event type owns rather than where the
908
+ key originally sat. CBOR maps are unordered, so this loses nothing — but a
909
+ byte-for-byte diff against the input will show it.
910
+
911
+ This matters most where it is least expected: adding a key to a reader's
912
+ vocabulary must never *reduce* what that reader preserves. Before a key is
913
+ defined, a wrong-typed value survives as an unrecognised key; after, a reader
914
+ that simply type-checks and moves on drops it. Both reference implementations
915
+ did precisely that when Event 1's keys were added, and lost values they had
916
+ previously kept.
917
+
918
+ Two bounds on the rule. A **required** key with an unusable type is a
919
+ malformed event, indistinguishable from that key being absent, because there
920
+ is no event to construct without it. And an unusable **optional** key MUST NOT fail
921
+ the event at all — it is the case this whole rule exists for, and the value
922
+ goes to the store.
923
+
924
+ That bound is about the event, not the API. Whether a malformed event ends the
925
+ read is a property of how a reader is driven: a streaming reader whose
926
+ idiomatic use collects into a fallible result stops at the first one, so for
927
+ that caller failing an event *is* failing the file. Readers MAY stop and MAY
928
+ continue, but MUST make which one is happening visible to the caller, and MUST
929
+ NOT report a partial event list as a complete one. Two conformant readers can
930
+ return different event counts for the same malformed file; nothing may depend
931
+ on them agreeing.
932
+
933
+ **What "unusable" means**, because two readers that draw this line differently
934
+ disagree about which values reach a field and which reach the store — and that
935
+ disagreement is invisible until someone diffs their output:
936
+
937
+ - A CBOR integer and a float with an integral value are both usable for an
938
+ integer key. Files carrying the float form exist and readers already MUST
939
+ accept them (see [Interoperability](#interoperability)); this rule does not
940
+ take that back.
941
+ - A number with a fractional part is **unusable** for an integer key.
942
+ - A negative value is **unusable** for a key this document defines as
943
+ unsigned. Every identifier here is a wire varint and has no negative form.
944
+ - A value outside the range the key's meaning allows is **unusable** where the
945
+ key is optional — `"sg"` as `-1`. Where the key is **required**, a reader
946
+ that can represent the value MUST still build the event: `"pp": 999` is a
947
+ number no conformant writer produces, but discarding the event loses far
948
+ more than the odd priority does. Report it if you like; do not delete it.
949
+ A required value the reader genuinely cannot represent remains malformed,
950
+ which is the same outcome as the key being absent.
951
+ - An implementation that holds a key in a type narrower than the key's range
952
+ MUST treat an unrepresentable value as unusable rather than rounding it.
953
+ Rounding invents a value the file never carried. This is why the identifier
954
+ keys must be held in a 64-bit-capable type: in a language where the ordinary
955
+ number type stops being exact at 2^53, using it for a `u64` identifier makes
956
+ that reader disagree with every other one above 2^53 — silently, and only
957
+ for large values, which is to say only in production.
958
+ - **On a rewrite, the reader rule outranks the writer rule.** A tool that reads
959
+ a trace and writes it back is bound by what it read, not by what a recorder
960
+ should have written. Where this document tells a writer MUST NOT emit
961
+ something — a `"msg"` that is not a map, a key outside its stream-type scope —
962
+ and tells a reader to keep it, a rewrite **keeps it**. The writer rules bind a
963
+ recorder deciding what to say about traffic it just saw; they do not license a
964
+ redaction pass, a filter or a format converter to quietly correct someone
965
+ else's file into one that no longer says what happened. A tool that wants to
966
+ report non-conformance should report it, not silently repair it.
967
+ - **Keys beginning `"x-"` are reserved for private use.** No revision of this
968
+ specification will define one, so a tool may add its own without ever
969
+ colliding with a future key — and a conformance test can rely on such a key
970
+ staying unrecognised. Every rule above applies to them unchanged: ignore them,
971
+ and preserve them on a rewrite. Absent this reservation there is no key an
972
+ implementation can be *sure* stays unknown, which matters more than it sounds:
973
+ a test fixture built from keys borrowed from a live proposal silently stops
974
+ testing anything the moment that proposal ships, and this specification did
975
+ exactly that to its own corpus once.
238
976
  - The detail level in the header is informational — readers MUST handle missing fields gracefully regardless of declared level.
239
977
 
978
+ ### Why version 2 exists
979
+
980
+ Every field this revision adds is additive and would have been legal in a version-1 file under the rules above. **Segmentation is the exception, and it is the sole reason for the bump.** A segmented stream splices a preamble — magic bytes, version, header length, header — into the middle of what a version-1 reader believes is an uninterrupted CBOR sequence. That reader decodes the `M` of `MOQTRACE` (`0x4D`) as the start of a 13-byte CBOR byte string, consumes 13 bytes of the preamble, and desynchronizes with no way to recover. Nothing in version 1 lets it detect the boundary or resynchronize past it.
981
+
982
+ Declaring version 2 converts that silent corruption into an explicit rejection at byte 8. A source that never writes segmented traces could in principle keep writing version 1, but SHOULD NOT: one declared version per revision keeps the compatibility matrix a line rather than a grid.
983
+
240
984
  ## Interoperability
241
985
 
242
986
  The `.moqtrace` format is shared between the JavaScript (`@moqtap/trace`) and Rust (`moqtap-trace`) implementations. A file written by either is readable by both. CBOR libraries used:
@@ -245,3 +989,220 @@ The `.moqtrace` format is shared between the JavaScript (`@moqtap/trace`) and Ru
245
989
  - Rust: `ciborium`
246
990
 
247
991
  Both produce deterministic CBOR output (canonical CBOR is NOT required, but map keys SHOULD be sorted for consistency).
992
+
993
+ Two encoding choices are **normative**, because a CBOR library's defaults are not necessarily readable by another CBOR library:
994
+
995
+ - **An integral value MUST be written as a CBOR integer (major type 0 or 1), not as a float.** CBOR permits a float carrying an integral value, and some encoders emit one for any number beyond 32 bits — which every epoch-millisecond timestamp is. A reader MUST nevertheless accept the float form for any integer field, rejecting only a value that is fractional or outside the range a float represents exactly, since files written that way exist.
996
+ - **A byte string MUST be written as major type 2, not wrapped in the typed-array tag 64 of RFC 8746.** A reader MUST nevertheless accept tag 64 wrapping a byte string, for the same reason.
997
+
998
+ The first rule is about the **value**, not about the type this document gives
999
+ the key. `"effectiveRate"` is defined as a float and its most common value is
1000
+ `1.0` — "no rate-based dropping" — which is integral, so it is written as the
1001
+ CBOR integer `1`. Conversely a reader MUST accept a CBOR integer for a key this
1002
+ document types as a float, which is the mirror of the sentence above and was
1003
+ left implicit for one revision too long: the two reference implementations
1004
+ disagreed on exactly this value, one writing a float64 and one an integer, for
1005
+ a key whose commonest setting triggers it.
1006
+
1007
+ Both directions are byte-level differences that preserve the number exactly.
1008
+ That is the general shape of these rules: they exist so that two encoders
1009
+ produce the same bytes for the same trace, not because either encoding loses
1010
+ anything.
1011
+
1012
+ Both rules exist because both were broken in opposite directions by the two implementations, and neither test suite could see it: each read only bytes it had written itself, so each agreed with a convention the other did not implement.
1013
+
1014
+ This claim is maintained by a shared corpus of `.moqtrace` files that both implementations read and write as part of their test suites, covering at minimum: a version-1 file, a non-segmented version-2 file, a segmented version-2 stream, a file carrying an unknown event type, a file carrying an unknown perspective, and a truncated file. An implementation that cannot round-trip the corpus is not conformant, whatever this document says.
1015
+
1016
+ The corpus is [`moqtrace/` in the `test-traces` repository](https://github.com/moqtap/test-traces/tree/master/moqtrace), published as `@moqtap/test-traces`. Its `manifest.json` indexes every case with the format version, segment count, header fields, event count and event-type histogram a conformant reader must agree on, so a third implementation can use it without reading either reference implementation. Two files there carry the non-canonical encodings the rules above require readers to accept — integers as floats, and byte strings under tag 64 — because a rule with no file exercising it is a rule nobody is held to.
1017
+
1018
+ ### Shapes a CBOR library may normalise before you see them
1019
+
1020
+ Every rule in this document about preserving a value assumes the reader is
1021
+ handed what the file carried. Sometimes it is not. A CBOR library may normalise
1022
+ a decoded value before any code written against this specification runs, and in
1023
+ each case below the normalisation is invisible: the reader cannot report a
1024
+ difference it was never shown.
1025
+
1026
+ The two reference implementations are known to do the following, and neither
1027
+ can be configured out of it without changing decoders:
1028
+
1029
+ | Shape in the file | What one library hands back |
1030
+ | ----------------- | --------------------------- |
1031
+ | An integer map key, `1` | The text key `"1"` — which then collides with a real `"1"` |
1032
+ | The text key `"__proto__"` | The text key `"__proto_"` — which then collides with a real `"__proto_"` |
1033
+ | The same key twice † | One entry — `cbor-x` keeps the last. **`ciborium` keeps both**, so a Rust reader *can* see this one |
1034
+ | CBOR `undefined` (major type 7, value 23) | CBOR `null` |
1035
+ | A byte string under RFC 8746 tag 64 | A bare byte string |
1036
+ | A float carrying an integral value | An integer |
1037
+
1038
+ † **The duplicate-key row is asymmetric, and that asymmetry is the point.**
1039
+ `ciborium` models a map as a list of pairs and hands back both entries, so part
1040
+ 2 binds a Rust reader there: it can see the duplicate and must not delete
1041
+ either entry. `cbor-x` has already collapsed the pair before its caller runs,
1042
+ so part 3 applies to a JavaScript reader on the same file. A row in this table
1043
+ is not automatically excused for every implementation — read parts 2 and 3
1044
+ against the library actually in use, and expect a shape to be observable to one
1045
+ reader and invisible to the other.
1046
+
1047
+ **The rule, in three parts.**
1048
+
1049
+ 1. **A writer MUST NOT *choose* any shape in that table.** Nothing this format
1050
+ defines needs one, so a recorder composing a trace out of what it observed
1051
+ has no reason to produce one, and this is where the requirement can actually
1052
+ be met.
1053
+
1054
+ This binds what a writer **originates**, not what it relays. A store may
1055
+ legitimately hold one of these shapes — the Rust reader hands back
1056
+ `"__proto__"` and an integer map key intact — and writing that store back
1057
+ offers exactly three options: drop the entry, rename it, or emit it as it
1058
+ stands. The first loses a value and the second invents one, and part 2
1059
+ forbids both. So the third is what a conformant rewrite does, and this rule
1060
+ does not stand in its way: **a stored entry is written as it stands.**
1061
+ Anything else would make a reader non-conformant for faithfully preserving
1062
+ what it was given, which is the opposite of what this section is for.
1063
+
1064
+ **Two rows are exceptions, for opposite reasons.** The last two — an
1065
+ integral float, and a byte string under tag 64 — are exceptions because a
1066
+ value-preserving re-encoding *exists*: rewriting them costs nothing but
1067
+ bytes, and the paragraph after this one requires it. The duplicate-key row
1068
+ is an exception because no such re-encoding exists at all: a map cannot
1069
+ carry one key twice and still be a map any two readers agree on, so a
1070
+ rewrite must drop an entry, and dropping it loses a value. That is the one
1071
+ place this document knowingly accepts a loss, and it is set out at the end
1072
+ of this section rather than waved at here.
1073
+ 2. **Where a reader can observe the shape, it MUST NOT substitute a different
1074
+ one.** Route it to the unrecognised-key store and preserve it, or report the
1075
+ value as unusable — but do not hand back a value the file did not carry. A
1076
+ reader that turns `undefined` into `null`, or `"__proto__"` into
1077
+ `"__proto_"`, has invented data, and it has done so in the one code path
1078
+ whose entire purpose is preservation.
1079
+ 3. **Where a reader cannot observe it, the reader is not non-conformant, and
1080
+ nothing may depend on the outcome.** A rule that requires reporting a
1081
+ difference the reader was never shown is a rule no one can implement, and
1082
+ this document does not issue one.
1083
+
1084
+ Part 3 is not an escape hatch for the merely inconvenient. It applies only
1085
+ where the normalisation happens *below* the implementation — where the value
1086
+ reaching this specification's code is already the normalised one. Anything the
1087
+ reader can still see, part 2 governs.
1088
+
1089
+ **The last two rows are different from the rest**, and are the exception that
1090
+ proves the rule: this document already requires readers to accept them and
1091
+ writers not to produce them (see the two normative encoding choices above).
1092
+ They are listed here because a reader that *preserves* one — a byte string
1093
+ still wrapped in tag 64, sitting in a store — is preserving a shape its own
1094
+ writer rule forbids, and would then emit it.
1095
+
1096
+ **So the two encoding rules apply to stored values too.** When a store is
1097
+ written back, an integral float in it is written as an integer and a tag-64
1098
+ byte string as major type 2, at any depth. That is a change of encoding and not
1099
+ of value, which is exactly the line this document already draws elsewhere:
1100
+ "written back unchanged" binds the value, not its encoding. The alternative —
1101
+ verbatim bytes — cannot be implemented by a reader whose decoder already
1102
+ normalised them on the way in, so requiring it would make one implementation
1103
+ permanently non-conformant while the other did nothing useful with its
1104
+ compliance.
1105
+
1106
+ One value does change under this rule: negative zero, whose sign is lost when
1107
+ `-0.0` is written as `0`. It is called out here rather than carved out, because
1108
+ a trace has no field where negative zero means anything, and an exception would
1109
+ cost more than it buys.
1110
+
1111
+ **Duplicate keys.** A writer MUST NOT emit a map carrying the same key twice.
1112
+ Readers are not required to agree about which entry of a duplicate pair wins,
1113
+ and no file may depend on it. RFC 8949 already calls such a map invalid; this
1114
+ document adds that a conformant tool must not *emit* one, having read one.
1115
+
1116
+ Two places produce one without anybody meaning to. A store built by hand is an
1117
+ ordered list of pairs rather than a map, so nothing stops a caller putting the
1118
+ same key in twice. And a map **nested inside** a stored value can carry a
1119
+ duplicate that came from the file itself — which is the more likely of the two,
1120
+ since it needs no mistake at all, only a non-conformant peer. The rule reaches
1121
+ both: a tool that read one must not emit one, at any depth.
1122
+
1123
+ **This is the one rule in this document that loses data, and it does so
1124
+ knowingly.** Collapsing a duplicate pair to a single entry discards the other
1125
+ entry's value. The alternative is emitting a map RFC 8949 calls invalid, which
1126
+ every downstream reader would then resolve differently — so the choice is not
1127
+ between losing a value and keeping it, but between losing it once, here, and
1128
+ losing it unpredictably at each reader thereafter. The input was already
1129
+ invalid; no rewrite of it can be lossless. Where the loss matters, report the
1130
+ duplicate rather than passing it on silently.
1131
+
1132
+ **And the two implementations will not lose the same one.** The distinction
1133
+ worth keeping straight, because the table above makes it and this paragraph
1134
+ once contradicted it: `ciborium` keeps **both** entries and makes no choice at
1135
+ all, so the Rust *reader* is the thing that keeps the first, by the first-match
1136
+ lookup it uses for every key. `cbor-x` has kept only the last before its caller
1137
+ runs. So the same input file rewritten through each yields two files differing
1138
+ in **value**, not merely in encoding.
1139
+
1140
+ That is a worse divergence than anything the two encoding rules address, and it
1141
+ is stated here rather than left to be discovered because it cannot be fixed:
1142
+ making one of them normative would require the other to report a difference its
1143
+ decoder discarded before any of this document's code ran. Keeping the first
1144
+ entry is the better behaviour where the choice exists, and it is what a
1145
+ first-match reader does anyway — but it is a SHOULD only the implementation
1146
+ that can still see both entries can be held to.
1147
+
1148
+ That asymmetry is exactly why this is worth a rule. A reader handed both
1149
+ entries can *delete* one silently, which a reader handed one cannot; the
1150
+ capable implementation is the one at risk, and both reference implementations
1151
+ did lose a value here — the Rust one on its read path, for a key a field had
1152
+ already taken — until it was found by inspection rather than by any test.
1153
+
1154
+ What keeps this from mattering is the rule at the top of this paragraph: **no
1155
+ conformant writer produces a duplicate key**, so a file carrying one came from
1156
+ a non-conformant peer, and a trace of a non-conformant peer is exactly the
1157
+ thing [Event 6](#event-6-error) exists to record. Record it there; do not rely
1158
+ on the rewrite.
1159
+
1160
+ ---
1161
+
1162
+ # Appendix A: MoQT Carriage Convention (informative)
1163
+
1164
+ > **Scoping note.** This appendix describes how a live `.moqtrace` stream maps onto a MoQT track. It is transport behavior, not file-format behavior, and is expected to migrate into a separate **moqtap** protocol draft. It is included here only so early implementers have a single reference while the drafts stabilize. The normative Part 1 and Part 2 of this document do not depend on anything in this appendix, and no released implementation currently provides it.
1165
+
1166
+ When a `.moqtrace` stream is carried over MoQT as a track:
1167
+
1168
+ - One MoQT **group** carries one segment (as defined in [Segmented Traces](#segmented-traces)).
1169
+ - **Object 0** of each group carries the segment header bytes: `MOQTRACE` + version + headerLen + headerCBOR.
1170
+ - **Objects 1..N** of each group each carry exactly one CBOR-encoded event.
1171
+ - Concatenating all object payloads of a group, in order, reproduces a valid single-segment `.moqtrace` file.
1172
+ - A subscriber joining at the latest group receives object 0 first and can begin parsing immediately without prior context.
1173
+
1174
+ Implementations MAY batch multiple events into one object for efficiency at high event rates; if so, each object payload remains a valid CBOR sequence.
1175
+
1176
+ **Late-subscriber join latency.** A subscriber joining mid-segment using SUBSCRIBE Filter Type `Largest Object` (0x2) starts at the next object after the current largest, missing the segment header object — its trace is unparseable until the next group begins. Subscribers SHOULD therefore use Filter Type `Next Group Start` (0x1), which delivers object 0 (the header) of the next group first. Worst-case time-to-first-event is one segment duration. Producers SHOULD pick segment duration to balance join latency against per-segment overhead; 1 s suits interactive debugging, longer suits archival capture.
1177
+
1178
+ **On-demand production.** A source SHOULD NOT produce trace events when no subscriber is attached. The producer launches when the first subscriber arrives, segment 0 begins at that moment, and the producer stops (or pauses) when the last subscriber leaves. Sources requiring retroactive history (FETCH-style backfill) need a ring buffer; this is out of scope for this specification.
1179
+
1180
+ **Refusing recording on overload.** When a source would otherwise drop a non-droppable event type (see [Non-droppable Event Types](#non-droppable-event-types)), the carriage convention is to return `REQUEST_ERROR` on the subscription rather than emit a sampled trace.
1181
+
1182
+ ---
1183
+
1184
+ # Changes from version 1
1185
+
1186
+ - **Format version bumped to 2**, solely to make segmentation detectable. See [Why version 2 exists](#why-version-2-exists).
1187
+ - **Readers must now accept both versions.** Version 1 said readers "MUST reject files with a version they don't support", which reads as license to reject version 1 once version 2 exists. It is now explicit that a conformant reader accepts 1 and 2.
1188
+ - **Document restructured** into a normative event model (Part 1), a normative binary container (Part 2), and an informative MoQT carriage appendix. Event semantics are no longer fused with container semantics, so the model can be reused in other containers.
1189
+ - **New Identifier Scoping section** distinguishing source-local `"p"`, capture-correlation `"sessionId"`, and federated `"traceId"`, with their orthogonality and presence rules.
1190
+ - **New Privacy Considerations section** flagging payload-bearing events (Event 4 `"pl"`, Event 0 `"raw"`, detail levels `"headers+data"` and `"full"`) as optional and high-risk, requiring operator opt-in and recommending default-off for general-purpose tools.
1191
+ - **New header field `"segment"`** enabling [Segmented Traces](#segmented-traces).
1192
+ - **New header field `"sampling"`** declaring effective rate, drop policy, drop counters, and the source-side filter rule.
1193
+ - **New `"perspective"` value `"relay-tap"`**, distinguishing a relay reporting on multiple concurrent peer sessions from `"server"` (single-session view) and `"observer"` (passive sniffer).
1194
+ - **New common event field `"p"`** — peer identifier, required for `"relay-tap"` so multi-peer events can be demultiplexed.
1195
+ - **New event types 8 (Peer connected), 9 (Peer disconnected) and 10 (Subscription derivation).**
1196
+ - **Event 10 carries track identity and per-hop timestamps** — `"ns"`, `"tn"`, `"tdr"`, `"tus"`, `"tuo"`, `"tdo"` — making per-hop latency attribution possible from the event alone, plus explicit update semantics for events emitted before all four timestamps are known.
1197
+ - **Event 10's trace ID is `"traceId"`, a 16-byte byte string.** An earlier revision of this proposal spelled it `"trace"` and left the type open; a text-string spelling invites per-implementation encodings, which defeats the cross-operator stitching the field exists for.
1198
+ - **Unknown event types, perspectives and detail levels must be tolerated, not rejected.** Version 1 said readers "SHOULD skip unknown event types"; neither reference implementation did, which is why the purely additive parts of this revision would have broken them anyway.
1199
+ - **Unrecognised keys must survive a read-modify-write.** Version 1 said only that unknown keys must be ignored, which both implementations read as permission to drop them — so every optional key either revision adds was safe to read past and silently destroyed by any tool that rewrote the file.
1200
+ - **The key-preservation rule now says what it means for the header**, in a section of its own. "Header maps" was plural and neither implementation read it that way: both dropped every unknown header key outright, which is the same data loss as on an event and strictly larger, since it applies to every unknown key rather than only a wrong-typed one. The header has three maps with named keys — the header itself, `"segment"` and `"sampling"` — and each keeps its own store. See [Unrecognised keys in the header](#unrecognised-keys-in-the-header).
1201
+ - **Event 6 can carry the bytes behind the error** — `"sid"`, `"ek"`, a `"rawlen"` gated at `"headers+sizes"` and a `"raw"` gated at `"full"`, with a normative 4096-byte cap and a one-per-flow latch. Version 1's error event held a code and a sentence, so a recorder that wanted to keep the offending bytes had to record the violation as a control message to have a field to put them in. See [Event 6](#event-6-error).
1202
+ - Event `"n"` and `"t"` clarified as **segment-local** in segmented traces.
1203
+ - **Non-droppable event types rule** added to the Sampling section.
1204
+ - **Trace ID Propagation section** defining the role of the `MOQTAP_TRACE_ID` extension parameter.
1205
+ - **Truncated files** now have defined reader behavior: report distinctly, return what decoded.
1206
+ - **Two CBOR encoding choices are now normative** — integral values as integers, byte strings as major type 2 — with readers required to accept the other form as well. See [Interoperability](#interoperability).
1207
+ - `"payloadMasked"` advertisement strengthened from SHOULD to MUST when masking is active.
1208
+ - The header table's `"protocol"` examples (`"draft-14"`, `"rfc9999"`) contradicted the Protocol Identifier section below it; corrected to `"moq-transport-17"` / `"moq-transport-rfc9999"`.