@blockcast/mmt-container 2.1.0 → 2.2.0-main.a88c3a0aa4f3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockcast/mmt-container",
3
- "version": "2.1.0",
3
+ "version": "2.2.0-main.a88c3a0aa4f3",
4
4
  "description": "MMTP/LOC container parsing for MMT over MoQ and SSM",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -169,10 +169,10 @@
169
169
  "test:watch": "vitest"
170
170
  },
171
171
  "dependencies": {
172
- "@blockcast/fec-worker": "^2.1.0",
173
- "@blockcast/mmt-base": "^2.1.0",
174
- "@blockcast/mmt-fec": "^2.1.0",
175
- "@blockcast/mmt-manifest-verify": "^2.1.0",
172
+ "@blockcast/fec-worker": "2.2.0-main.a88c3a0aa4f3",
173
+ "@blockcast/mmt-base": "2.2.0-main.a88c3a0aa4f3",
174
+ "@blockcast/mmt-fec": "2.2.0-main.a88c3a0aa4f3",
175
+ "@blockcast/mmt-manifest-verify": "2.2.0-main.a88c3a0aa4f3",
176
176
  "@noble/curves": "^1.9.7",
177
177
  "@noble/hashes": "^1.8.0",
178
178
  "ajv": "^8.17.1",
@@ -28,6 +28,11 @@ export interface ManifestVerificationStats {
28
28
 
29
29
  /** Transport state snapshot, built by the player wiring layer from SocketPool. */
30
30
  export interface TransportSnapshot {
31
+ /**
32
+ * CMCD has no user-agent value: report a `'user-agent'` join
33
+ * (`ResolvedMulticastTransport`, the browser's own MulticastReceiver) as
34
+ * `'native'`, since the browser does not say which leg it chose.
35
+ */
31
36
  transport: 'native' | 'amt' | 'moq'
32
37
  /** IPv4/IPv6 multicast group address */
33
38
  multicastGroup?: string
@@ -23,6 +23,7 @@ import {
23
23
  type MMTPHeader,
24
24
  type MoqRepairFrame,
25
25
  } from "@blockcast/mmt-base";
26
+ import { USER_AGENT_VERIFIED } from "@blockcast/mmt-transport";
26
27
  import type { FECManager, IsoHeldSource, WasmFecDecoder } from "./fec-manager.js";
27
28
  import type { IsoPlacedSource, IsoRepairPlacement } from "./iso-block-locator.js";
28
29
  import { isMfuReassemblyError } from "./mfu-reassembler.js";
@@ -245,6 +246,14 @@ export interface MmtpIngressContext {
245
246
  * the same ingress and `feedPacket` splits them off.
246
247
  */
247
248
  flow?: string;
249
+ /**
250
+ * `USER_AGENT_VERIFIED` when the datagram came from the browser's own
251
+ * MulticastReceiver, which verified the flow's records itself and delivers
252
+ * only admitted data (`userAgentVerifiedIngress` in
253
+ * `@blockcast/mmt-transport`). A protected multicast datagram carrying it
254
+ * is admitted without a record. Any other value means nothing.
255
+ */
256
+ admittedBy?: symbol;
248
257
  }
249
258
 
250
259
  /** One physical stream: an MMTP packet ID and the catalog track it carries. */
@@ -946,6 +955,15 @@ function sourceSymbolEvidence(
946
955
  };
947
956
  }
948
957
 
958
+ /** One protected flow: its 7.2.7 receiver, and the ingress its datagrams arrive with. */
959
+ interface ProvenanceEntry {
960
+ receiver: BcProvenanceV2Receiver;
961
+ ingress: MmtpIngressContext;
962
+ protected?: boolean;
963
+ /** A datagram the user agent verified admitted the flow (`admittedBy`). */
964
+ userAgent?: boolean;
965
+ }
966
+
949
967
  export class MmtpRouter {
950
968
  readonly #parser: MmtpContainerParser;
951
969
  // Source AND FEC-recovered MFU fragments share ONE reassembler — the parser's
@@ -955,10 +973,7 @@ export class MmtpRouter {
955
973
  #defaultFecMapper: FecBlockMapper | null;
956
974
  #fecMappersByPacketId = new Map<number, FecBlockMapper>();
957
975
  /** Protected flows: one 7.2.7 receiver each, and the ingress its datagrams arrive with. */
958
- readonly #provenance = new Map<
959
- string,
960
- { receiver: BcProvenanceV2Receiver; ingress: MmtpIngressContext; protected?: boolean }
961
- >();
976
+ readonly #provenance = new Map<string, ProvenanceEntry>();
962
977
  #onFlowProtected: ((flow: string) => void) | null = null;
963
978
  #unadmittedListedDrops = 0;
964
979
  #unlistedRepairSetAside = 0;
@@ -2329,12 +2344,13 @@ export class MmtpRouter {
2329
2344
  if (!entry) return;
2330
2345
  entry.receiver.release();
2331
2346
  entry.protected = false;
2347
+ entry.userAgent = false;
2332
2348
  // ponytail: drops every governed block's record, not only this flow's; per-flow if joins multiply.
2333
2349
  this.#assistBlocks.clear();
2334
2350
  this.#assistFailures.clear();
2335
2351
  }
2336
2352
 
2337
- /** MoQ packets of a track a protected flow lists, dropped on its unicast path: duplicate, overflow, or listed by more than one flow. */
2353
+ /** Packets of a track a protected flow lists, dropped unread: a MoQ copy (duplicate, overflow, or listed by more than one flow) or a multicast source datagram that names no flow, of a track a user-agent-admitted flow lists. */
2338
2354
  get unadmittedListedDropCount(): number {
2339
2355
  return this.#unadmittedListedDrops;
2340
2356
  }
@@ -2384,6 +2400,14 @@ export class MmtpRouter {
2384
2400
  return false;
2385
2401
  }
2386
2402
 
2403
+ /** Whether a protected flow the user agent admitted lists `packetId`. */
2404
+ #userAgentGoverns(packetId: number): boolean {
2405
+ for (const entry of this.#provenance.values()) {
2406
+ if (entry.protected && entry.userAgent && ingressHasPacketId(entry.ingress, packetId)) return true;
2407
+ }
2408
+ return false;
2409
+ }
2410
+
2387
2411
  /**
2388
2412
  * A MoQ repair symbol no protected flow lists, owned by a source track one
2389
2413
  * does: its FEC instance is governed (7.2.7 step 2) and takes only admitted
@@ -2503,7 +2527,7 @@ export class MmtpRouter {
2503
2527
  fec.cancelBlockTimeout(blockStart, ownerPacketId, catalogTrackName);
2504
2528
  fec.countRecovery(ownerPacketId, blockStart, catalogTrackName);
2505
2529
  fec.markBlockCompleted(blockStart, ownerPacketId, catalogTrackName);
2506
- })) {
2530
+ }, true)) {
2507
2531
  this.#unlistedAssistedRecoveries++;
2508
2532
  this.#assistBlocks.delete(key);
2509
2533
  return;
@@ -3959,6 +3983,15 @@ export class MmtpRouter {
3959
3983
  debugRouter(`[MmtpRouter] Dropping multicast packet pid=${info.packetId}: flow ${ingress.flow} has no receiver`);
3960
3984
  return;
3961
3985
  }
3986
+ // The user agent verified this datagram against the flow's records
3987
+ // itself, and the records never reach the page: admitted as is. The
3988
+ // flow stays registered, so its MoQ copies are still checked.
3989
+ if (ingress.admittedBy === USER_AGENT_VERIFIED) {
3990
+ this.#noteFlowAdmitted(ingress.flow);
3991
+ protectedFlow.userAgent = true;
3992
+ this.#processMMTP(packet, source, true, ingress, overlapCopy);
3993
+ return;
3994
+ }
3962
3995
  // Held: released by a record (feedProvenanceRecord) or expired. Duplicate / overflow: dropped.
3963
3996
  const { verdict } = protectedFlow.receiver.onDatagram(packet, "multicast");
3964
3997
  if (verdict === "admitted") {
@@ -3983,6 +4016,18 @@ export class MmtpRouter {
3983
4016
  }
3984
4017
  }
3985
4018
 
4019
+ // A multicast datagram that names no flow is unverified. Of a track a
4020
+ // user-agent-admitted flow lists it would feed that flow's live decoder,
4021
+ // whose recoveries are kept without a record (#routeVerifiedFecSymbols):
4022
+ // its source is dropped here and its repair set aside below, as for
4023
+ // unlisted MoQ. Under a record-only flow it is read as before: those
4024
+ // recoveries still need a record.
4025
+ const flowless = source === "multicast" && !manifestAdmitted && ingress?.flow === undefined;
4026
+ if (flowless && !info.isRepair && this.#userAgentGoverns(info.packetId)) {
4027
+ this.#unadmittedListedDrops++;
4028
+ return;
4029
+ }
4030
+
3986
4031
  if (info.isRepair) {
3987
4032
  // Repair packet — per ISO 23008-1 §C.5.3, carries (SS_Start, SSB_length, RS_ID).
3988
4033
  // Read from the header parsed above rather than parsing it again
@@ -4003,9 +4048,10 @@ export class MmtpRouter {
4003
4048
  );
4004
4049
  if (!repair) return;
4005
4050
  if (ownerPacketId != null && this.#fecManager) {
4006
- // An unadmitted MoQ symbol reaching here is unlisted (#admitUnicast).
4051
+ // An unadmitted MoQ symbol reaching here is unlisted (#admitUnicast);
4052
+ // a flow-less multicast one is unverified.
4007
4053
  if (
4008
- source === "moq" && !manifestAdmitted &&
4054
+ ((flowless && this.#userAgentGoverns(ownerPacketId)) || (source === "moq" && !manifestAdmitted)) &&
4009
4055
  this.#setAsideUnlistedRepair(ownerPacketId, catalogTrackNameForPacket(ingress, ownerPacketId), repair)
4010
4056
  ) return;
4011
4057
  this.#repairPidCounts[info.packetId] = (this.#repairPidCounts[info.packetId] || 0) + 1;
@@ -4950,6 +4996,7 @@ export class MmtpRouter {
4950
4996
  ownerPacketId?: number,
4951
4997
  catalogTrackName?: string,
4952
4998
  complete?: () => void,
4999
+ assisted = false,
4953
5000
  ): boolean {
4954
5001
  // MMTP path: use processFecBlock for MMTP-wrapped recovery.
4955
5002
  // Recovered symbols are individual MMTP packets that may be fragments
@@ -4974,7 +5021,7 @@ export class MmtpRouter {
4974
5021
  ownerPacketId,
4975
5022
  blockProfile,
4976
5023
  });
4977
- return this.#routeVerifiedFecSymbols(blockId, result, ownerPacketId, catalogTrackName, complete);
5024
+ return this.#routeVerifiedFecSymbols(blockId, result, ownerPacketId, catalogTrackName, complete, assisted);
4978
5025
  }
4979
5026
 
4980
5027
  /**
@@ -4987,6 +5034,19 @@ export class MmtpRouter {
4987
5034
  * recovered packets is refused is unrecovered (7.2.7): its caller leaves it
4988
5035
  * open, so it reaches its FEC deadline and fails by class. Always true
4989
5036
  * outside a protected flow.
5037
+ *
5038
+ * On a flow the user agent admitted, the live decoder's recoveries are kept
5039
+ * without a record: while the flow is protected that decoder takes only
5040
+ * symbols the user agent or a record verified (a flow-less multicast
5041
+ * datagram of a listed track is dropped or set aside in #processMMTP), so
5042
+ * what it recovers is as verified as they are. An `assisted` (scratch-decoder) recovery mixes in
5043
+ * unlisted MoQ repair and still needs a record.
5044
+ * ponytail: per-flow flag; per-block provenance only if a protected flow
5045
+ * ever mixes verified and unverified live inputs.
5046
+ * ponytail: a worker frame computed before the protection reset can still
5047
+ * arrive after it (frames carry no epoch); its inputs are MoQ copies read
5048
+ * and played before protection, so it admits nothing new. Drop pre-reset
5049
+ * frames by epoch only if pre-protection input ever matters.
4990
5050
  */
4991
5051
  #routeVerifiedFecSymbols(
4992
5052
  blockId: number,
@@ -4994,8 +5054,11 @@ export class MmtpRouter {
4994
5054
  ownerPacketId?: number,
4995
5055
  catalogTrackName?: string,
4996
5056
  complete?: () => void,
5057
+ assisted = false,
4997
5058
  ): boolean {
4998
- const receiver = ownerPacketId == null ? undefined : this.#provenanceFor(ownerPacketId, catalogTrackName);
5059
+ const entry = ownerPacketId == null ? undefined : this.#provenanceFor(ownerPacketId, catalogTrackName);
5060
+ const receiver = entry === undefined || entry === "ambiguous" ? entry : entry.receiver;
5061
+ const userAgent = !assisted && entry !== undefined && entry !== "ambiguous" && entry.protected === true && entry.userAgent === true;
4999
5062
  let recovered = true;
5000
5063
  if (receiver) {
5001
5064
  const parsed = this.#stream(ownerPacketId!, catalogTrackName).duplicates.parsedSymbols;
@@ -5008,6 +5071,8 @@ export class MmtpRouter {
5008
5071
  let admitted = false;
5009
5072
  if (receiver === "ambiguous") {
5010
5073
  this.#ambiguousRecoveredDrops++;
5074
+ } else if (userAgent) {
5075
+ admitted = true;
5011
5076
  } else {
5012
5077
  const wire = new Uint8Array(sym.rawSymbol.byteLength + 4);
5013
5078
  wire.set(sym.rawSymbol);
@@ -5033,7 +5098,7 @@ export class MmtpRouter {
5033
5098
  #provenanceFor(
5034
5099
  packetId: number,
5035
5100
  catalogTrackName: string | undefined,
5036
- ): BcProvenanceV2Receiver | "ambiguous" | undefined {
5101
+ ): ProvenanceEntry | "ambiguous" | undefined {
5037
5102
  // A caller that names no track matches every name the packet id was admitted under.
5038
5103
  const byName = this.#provenanceStreamFlow.get(packetId);
5039
5104
  const flows = new Set(
@@ -5042,12 +5107,12 @@ export class MmtpRouter {
5042
5107
  : byName.has(catalogTrackName) ? [byName.get(catalogTrackName)!] : [],
5043
5108
  );
5044
5109
  if (flows.size > 1) return "ambiguous";
5045
- for (const bound of flows) return this.#provenance.get(bound)?.receiver ?? "ambiguous";
5046
- let match: BcProvenanceV2Receiver | undefined;
5047
- for (const { receiver, ingress } of this.#provenance.values()) {
5048
- if (ingress.tracks.some((t) => t.packetId === packetId && (catalogTrackName === undefined || t.name === catalogTrackName))) {
5110
+ for (const bound of flows) return this.#provenance.get(bound) ?? "ambiguous";
5111
+ let match: ProvenanceEntry | undefined;
5112
+ for (const entry of this.#provenance.values()) {
5113
+ if (entry.ingress.tracks.some((t) => t.packetId === packetId && (catalogTrackName === undefined || t.name === catalogTrackName))) {
5049
5114
  if (match) return "ambiguous";
5050
- match = receiver;
5115
+ match = entry;
5051
5116
  }
5052
5117
  }
5053
5118
  return match;
@@ -50,7 +50,11 @@ import {
50
50
  type StreamSwitchCommit,
51
51
  type TrackPacketCallback,
52
52
  } from "./mmtp-router.js";
53
- import { isMulticastTrackPacketId, type WindowMulticastGateway } from "@blockcast/mmt-transport";
53
+ import {
54
+ isMulticastTrackPacketId,
55
+ userAgentVerifiedIngress,
56
+ type WindowMulticastGateway,
57
+ } from "@blockcast/mmt-transport";
54
58
  import {
55
59
  MulticastTransport,
56
60
  type MulticastFailbackReason,
@@ -2353,10 +2357,20 @@ export class MulticastManager {
2353
2357
  * repair roles and owners are registered here; in-band owners stay the
2354
2358
  * caller's. Joining is best effort: a group that fails to join is logged
2355
2359
  * and leaves the media join as it was.
2360
+ *
2361
+ * `options.gateway` names the gateway a subscribe callback reads through.
2362
+ * When it is the browser's own MulticastReceiver provider, the callback's
2363
+ * packets are fed as admitted by the user agent
2364
+ * (`userAgentVerifiedIngress`); nothing the callback passes can mark them.
2365
+ * The mark rests on this statement, not on the data path: every packet the
2366
+ * callback delivers must come from a subscription opened on
2367
+ * `options.gateway`. A callback that also merges another source (a MoQ or
2368
+ * WebSocket repair feed) must not name a native provider here.
2356
2369
  */
2357
2370
  joinRepairEndpoints(
2358
2371
  endpoints: readonly MulticastRepairEndpoint[],
2359
2372
  provider?: WindowMulticastGateway | MulticastRepairSubscribe,
2373
+ options?: { gateway?: WindowMulticastGateway },
2360
2374
  ): () => void {
2361
2375
  const releases: Array<() => void> = [];
2362
2376
  let released = false;
@@ -2382,22 +2396,26 @@ export class MulticastManager {
2382
2396
  ...(endpoint.flow === undefined ? {} : { flow: endpoint.flow }),
2383
2397
  };
2384
2398
  let sender: string | undefined;
2385
- const onPacket = (packet: Uint8Array, sourceAddress?: string) => {
2399
+ const feed = (packet: Uint8Array, sourceAddress: string | undefined, ingressToFeed: MmtpIngressContext) => {
2386
2400
  if (released) return;
2387
2401
  // The media join's sender owns the repair too; this group's own first
2388
2402
  // sender stands in only until the media join has one.
2389
2403
  if (sourceAddress !== undefined && !sameDatagramSource(sourceAddress, this.#transport.sender ?? (sender ??= sourceAddress))) return;
2390
- this.#router.feedPacket(packet, "multicast", ingress);
2404
+ this.#router.feedPacket(packet, "multicast", ingressToFeed);
2391
2405
  };
2392
2406
  const fail = (error: unknown) =>
2393
2407
  console.warn(`[MulticastManager] repair group ${endpoint.group}:${endpoint.port} not joined`, error);
2394
2408
  if (typeof provider === "function") {
2409
+ const marked = userAgentVerifiedIngress(ingress, options?.gateway);
2410
+ const onPacket = (packet: Uint8Array, sourceAddress?: string) => feed(packet, sourceAddress, marked);
2395
2411
  Promise.resolve()
2396
2412
  .then(() => provider(endpoint, onPacket))
2397
2413
  .then((release) => (released ? safely(release) : releases.push(release)), fail);
2398
2414
  continue;
2399
2415
  }
2400
- const transport = new MulticastTransport((packet) => onPacket(packet), {
2416
+ // The transport marks what it delivers when its gateway is the user
2417
+ // agent's MulticastReceiver provider; feedPacket would not.
2418
+ const transport = new MulticastTransport<MmtpIngressContext>((packet, delivered) => feed(packet, undefined, delivered ?? ingress), {
2401
2419
  gateway: provider,
2402
2420
  lockedTo: () => this.#transport.sender,
2403
2421
  });
@@ -2413,7 +2431,7 @@ export class MulticastManager {
2413
2431
  tracks: endpoint.tracks.map(({ name, packetId }) => ({ name, packetId })),
2414
2432
  // A protected repair group's records ride in band on its port (7.2.3).
2415
2433
  ...(endpoint.flow === undefined ? {} : { protected: true }),
2416
- }).then((joined) => {
2434
+ }, ingress).then((joined) => {
2417
2435
  if (!joined && !released) fail(transport.lastJoinOutcome);
2418
2436
  });
2419
2437
  }
@@ -6,7 +6,10 @@
6
6
 
7
7
  import {
8
8
  isMulticastTrackPacketId,
9
+ isUserAgentVerifiedGateway,
10
+ resolveMulticastGateway,
9
11
  subscriptionDeliversPacketBatches,
12
+ userAgentVerifiedIngress,
10
13
  toWindowMulticastSubscribeConfig,
11
14
  type WindowMulticastDelivery,
12
15
  type WindowMulticastGateway,
@@ -52,7 +55,13 @@ export interface MulticastTransportConfig {
52
55
  protected?: boolean
53
56
  }
54
57
 
55
- export type ResolvedMulticastTransport = 'native' | 'amt'
58
+ /**
59
+ * The leg a join resolved to. `'user-agent'`: the browser's own
60
+ * `MulticastReceiver` delivers it, which picks native or AMT itself and does
61
+ * not say which. CMCD has no user-agent value, so a caller reports a
62
+ * `'user-agent'` join as `'native'` (`TransportSnapshot.transport`).
63
+ */
64
+ export type ResolvedMulticastTransport = 'native' | 'amt' | 'user-agent'
56
65
 
57
66
  /**
58
67
  * Why a joined transport handed control back to MoQ.
@@ -211,16 +220,8 @@ function providerErrorOf(error: unknown): MulticastJoinProviderError | undefined
211
220
  */
212
221
  const MAX_TRACKED_DROPPED_PIDS = 16
213
222
 
214
- type MulticastGlobals = typeof globalThis & {
215
- multicast?: WindowMulticastGateway
216
- }
217
-
218
- function getMulticastGlobals(): MulticastGlobals {
219
- return globalThis as MulticastGlobals
220
- }
221
-
222
223
  function hasMulticastGateway(): boolean {
223
- return typeof getMulticastGlobals().multicast?.subscribe === 'function'
224
+ return typeof resolveMulticastGateway()?.subscribe === 'function'
224
225
  }
225
226
 
226
227
  /**
@@ -371,6 +372,9 @@ export class MulticastTransport<TPacketContext = never> {
371
372
  // listener closure, so an in-place admission change (setContext) applies
372
373
  // from the next packet on without rejoining.
373
374
  #context: TPacketContext | undefined = undefined
375
+ #deliveredContext: TPacketContext | undefined = undefined
376
+ /** The live join's gateway, when it is the user agent's MulticastReceiver provider. */
377
+ #userAgentGateway: WindowMulticastGateway | undefined = undefined
374
378
  // The live join's provider-side filter. An in-place widening must reach it,
375
379
  // or the provider never delivers the new ID; null without a live join.
376
380
  #providerFilter: ProviderFilter | null = null
@@ -415,7 +419,7 @@ export class MulticastTransport<TPacketContext = never> {
415
419
  this.#sender = undefined
416
420
 
417
421
  if (this.#disposed) return this.#declined(generation, 'disposed')
418
- const gateway = this.#gateway ?? getMulticastGlobals().multicast
422
+ const gateway = this.#gateway ?? resolveMulticastGateway()
419
423
  if (typeof gateway?.subscribe !== 'function') {
420
424
  console.warn('[MulticastTransport] Multicast API not available')
421
425
  return this.#declined(generation, 'unavailable')
@@ -435,15 +439,18 @@ export class MulticastTransport<TPacketContext = never> {
435
439
  console.log(
436
440
  `[MulticastTransport] window.multicast.subscribe(${config.sourceAddress ?? config.senderAddress ?? '*'}, ${config.groupAddress}, ${config.port})`,
437
441
  )
442
+ // The browser's MulticastReceiver picks native or AMT, and the relay,
443
+ // itself: ask it for the group only.
444
+ const userAgent = isUserAgentVerifiedGateway(gateway)
438
445
  const subscribeConfig = toWindowMulticastSubscribeConfig({
439
446
  source: config.sourceAddress,
440
447
  senderAddress: config.senderAddress,
441
448
  group: config.groupAddress,
442
449
  port: config.port,
443
- mode: config.mode,
444
- amtRelay: config.amtRelay,
445
- amtRelayPort: config.amtRelayPort,
446
- amtDiscovery: config.amtDiscovery,
450
+ mode: userAgent ? 'native' : config.mode,
451
+ amtRelay: userAgent ? undefined : config.amtRelay,
452
+ amtRelayPort: userAgent ? undefined : config.amtRelayPort,
453
+ amtDiscovery: userAgent ? undefined : config.amtDiscovery,
447
454
  packetIds: config.packetIds,
448
455
  tracks: config.tracks,
449
456
  atsc3: config.atsc3,
@@ -506,7 +513,8 @@ export class MulticastTransport<TPacketContext = never> {
506
513
  acknowledgedUpdate: 0,
507
514
  inFlight: 0,
508
515
  }
509
- this.#context = context
516
+ this.#userAgentGateway = userAgent ? gateway : undefined
517
+ this.#setContext(context)
510
518
  // Seed the allow-list's counters so accepted packets never insert. An
511
519
  // id already counted by an earlier join keeps its count: the counters
512
520
  // are cumulative across rejoins, as bytesReceived is.
@@ -521,7 +529,7 @@ export class MulticastTransport<TPacketContext = never> {
521
529
  // gateway no longer exposes a mutable transport/transportchange
522
530
  // surface, so assign this only after subscribe() has resolved and the
523
531
  // returned subscription has passed the event-surface check above.
524
- this.#resolvedTransport = subscribeConfig.origin === 'amt' ? 'amt' : 'native'
532
+ this.#resolvedTransport = userAgent ? 'user-agent' : subscribeConfig.origin === 'amt' ? 'amt' : 'native'
525
533
  // The provider alone knows whether its packets are independent
526
534
  // datagrams or an ordered relay of one socket; undeclared promises no
527
535
  // ordering. Announce it before the listener can deliver a packet. A
@@ -630,7 +638,7 @@ export class MulticastTransport<TPacketContext = never> {
630
638
  // Read per packet: a callback that changes the admission (an in-place
631
639
  // rendition switch committing on this very packet) takes effect from the
632
640
  // next packet, including the rest of a batch.
633
- this.#onPacket(packet, this.#context)
641
+ this.#onPacket(packet, this.#deliveredContext)
634
642
  return liveness
635
643
  }
636
644
 
@@ -872,8 +880,10 @@ export class MulticastTransport<TPacketContext = never> {
872
880
  }
873
881
 
874
882
  /**
875
- * What the live join delivers with every accepted packet (the join's
876
- * `context`, or the last setContext), or undefined without a live join.
883
+ * The live join's context as the caller gave it (the join's `context`, or
884
+ * the last setContext), or undefined without a live join. On a native
885
+ * provider each accepted packet is delivered with a marked copy of it
886
+ * (`userAgentVerifiedIngress`); this getter never carries the mark.
877
887
  */
878
888
  get context(): TPacketContext | undefined {
879
889
  return this.#context
@@ -899,7 +909,19 @@ export class MulticastTransport<TPacketContext = never> {
899
909
  '[MulticastTransport] setContext needs a live join; join() carries the first context',
900
910
  )
901
911
  }
912
+ this.#setContext(context)
913
+ }
914
+
915
+ /**
916
+ * The join's context, and what the subscription delivers with each packet:
917
+ * marked admitted by the user agent when the gateway is its native
918
+ * MulticastReceiver provider. feedPacket never marks.
919
+ */
920
+ #setContext(context: TPacketContext | undefined): void {
902
921
  this.#context = context
922
+ this.#deliveredContext = this.#userAgentGateway !== undefined && typeof context === 'object' && context !== null
923
+ ? userAgentVerifiedIngress(context, this.#userAgentGateway)
924
+ : context
903
925
  }
904
926
 
905
927
  /**
@@ -1040,6 +1062,8 @@ export class MulticastTransport<TPacketContext = never> {
1040
1062
  this.#matchedSource = undefined
1041
1063
  this.#providerFilter = null
1042
1064
  this.#context = undefined
1065
+ this.#deliveredContext = undefined
1066
+ this.#userAgentGateway = undefined
1043
1067
  this.#withdrawnPacketIds.clear()
1044
1068
  }
1045
1069