@blockcast/mmt-container 1.0.1 → 2.0.0

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.
Files changed (100) hide show
  1. package/README.md +9 -7
  2. package/dist/browser-runtime.js +6931 -6685
  3. package/dist/browser-runtime.js.map +4 -4
  4. package/dist/carp-catalog.d.ts +7 -7
  5. package/dist/carp-catalog.d.ts.map +1 -1
  6. package/dist/carp-catalog.js +65 -230
  7. package/dist/carp-catalog.js.map +3 -3
  8. package/dist/catalog-fec-params.js.map +1 -1
  9. package/dist/catalog-network-source.js.map +1 -1
  10. package/dist/delivery-catalog.js.map +1 -1
  11. package/dist/delta-fallback-config.js.map +1 -1
  12. package/dist/fec-manager.d.ts +25 -1
  13. package/dist/fec-manager.d.ts.map +1 -1
  14. package/dist/fec-manager.js +208 -15
  15. package/dist/fec-manager.js.map +2 -2
  16. package/dist/fec-state.js.map +1 -1
  17. package/dist/fec-worker-setup.js.map +1 -1
  18. package/dist/held-layer-admission.d.ts +8 -0
  19. package/dist/held-layer-admission.d.ts.map +1 -1
  20. package/dist/held-layer-admission.js +22 -1
  21. package/dist/held-layer-admission.js.map +2 -2
  22. package/dist/iso-block-locator.js.map +1 -1
  23. package/dist/mfu-reassembler.js.map +1 -1
  24. package/dist/mmtp-container-parser.js.map +1 -1
  25. package/dist/mmtp-container-parser.no-modules.js +127 -322
  26. package/dist/mmtp-container-parser.no-modules.js.map +4 -4
  27. package/dist/mmtp-decode-pipeline.js.map +1 -1
  28. package/dist/mmtp-fec-session.d.ts.map +1 -1
  29. package/dist/mmtp-fec-session.js +61 -15
  30. package/dist/mmtp-fec-session.js.map +3 -3
  31. package/dist/mmtp-router.d.ts +11 -6
  32. package/dist/mmtp-router.d.ts.map +1 -1
  33. package/dist/mmtp-router.js +47 -10
  34. package/dist/mmtp-router.js.map +3 -3
  35. package/dist/multicast-auth-catalog.d.ts +8 -8
  36. package/dist/multicast-auth-catalog.d.ts.map +1 -1
  37. package/dist/multicast-auth-catalog.js +171 -75
  38. package/dist/multicast-auth-catalog.js.map +4 -4
  39. package/dist/multicast-input.d.ts +5 -5
  40. package/dist/multicast-input.d.ts.map +1 -1
  41. package/dist/multicast-input.js +126 -49
  42. package/dist/multicast-input.js.map +3 -3
  43. package/dist/multicast-manager.d.ts +11 -4
  44. package/dist/multicast-manager.d.ts.map +1 -1
  45. package/dist/multicast-manager.js +7922 -7700
  46. package/dist/multicast-manager.js.map +4 -4
  47. package/dist/multicast-transport.d.ts +8 -0
  48. package/dist/multicast-transport.d.ts.map +1 -1
  49. package/dist/multicast-transport.js +27 -3
  50. package/dist/multicast-transport.js.map +3 -3
  51. package/dist/multicast-unwrap.js +2 -2
  52. package/dist/multicast-unwrap.js.map +2 -2
  53. package/dist/no-modules.js +81 -278
  54. package/dist/no-modules.js.map +4 -4
  55. package/dist/repair-fetch.js.map +1 -1
  56. package/dist/signaling.js.map +1 -1
  57. package/dist/timestamp-codec.js.map +1 -1
  58. package/dist/wasm-mfu-reassembler.js.map +1 -1
  59. package/dist/wasm-mmtp-container-parser.js.map +1 -1
  60. package/package.json +5 -5
  61. package/schemas/catalog.schema.json +15 -7
  62. package/src/carp-catalog.ts +14 -13
  63. package/src/fec-manager.ts +89 -14
  64. package/src/held-layer-admission.ts +38 -1
  65. package/src/mmtp-fec-session.ts +20 -12
  66. package/src/mmtp-router.ts +65 -16
  67. package/src/multicast-auth-catalog.ts +43 -36
  68. package/src/multicast-input.ts +28 -8
  69. package/src/multicast-manager.ts +38 -34
  70. package/src/multicast-transport.ts +31 -3
  71. package/test-vectors/bc-provenance-v2.json +614 -175
  72. package/test-vectors/catalog/README.md +24 -8
  73. package/test-vectors/catalog/negative/multicast-auth-asm-missing-sender-address.json +0 -2
  74. package/test-vectors/catalog/negative/{multicast-auth-manifest-port-equals-port.json → multicast-auth-draft01-manifest-port.json} +1 -1
  75. package/test-vectors/catalog/negative/multicast-auth-duplicate-first-epoch.json +0 -1
  76. package/test-vectors/catalog/negative/multicast-auth-duplicate-kid.json +0 -1
  77. package/test-vectors/catalog/negative/multicast-auth-endpoint-auth-without-object-auth.json +0 -1
  78. package/test-vectors/catalog/negative/multicast-auth-endpoint-missing-bandwidth.json +0 -1
  79. package/test-vectors/catalog/negative/multicast-auth-manifest-timer-out-of-range.json +0 -1
  80. package/test-vectors/catalog/negative/multicast-auth-sender-address-on-ssm.json +0 -1
  81. package/test-vectors/catalog/negative/multicast-auth-shared-sg-port.json +73 -0
  82. package/test-vectors/catalog/negative/multicast-auth-version-1.json +0 -1
  83. package/test-vectors/catalog/negative/multicast-auth-version-3.json +0 -1
  84. package/test-vectors/catalog/negative/multicast-auth-version-missing.json +0 -1
  85. package/test-vectors/catalog/negative/multicast-endpoint-address-family-mismatch.json +55 -0
  86. package/test-vectors/catalog/negative/multicast-endpoint-address-host-name.json +55 -0
  87. package/test-vectors/catalog/negative/multicast-endpoint-address-leading-zero.json +55 -0
  88. package/test-vectors/catalog/negative/multicast-endpoint-address-ls32-leading-zero.json +55 -0
  89. package/test-vectors/catalog/negative/multicast-endpoint-address-mapped-family-mismatch.json +55 -0
  90. package/test-vectors/catalog/negative/multicast-endpoint-address-zone-id.json +55 -0
  91. package/test-vectors/catalog/positive/multicast-auth-asm-sender.json +0 -2
  92. package/test-vectors/catalog/{negative/multicast-auth-max-auth-delay-above-max.json → positive/multicast-auth-max-auth-delay-above-max-clamped.json} +0 -1
  93. package/test-vectors/catalog/{negative/multicast-auth-max-auth-delay-below-min.json → positive/multicast-auth-max-auth-delay-below-min-clamped.json} +0 -1
  94. package/test-vectors/catalog/positive/multicast-auth-max-auth-delay-string.json +55 -0
  95. package/test-vectors/catalog/positive/multicast-auth-rotation.json +0 -1
  96. package/test-vectors/catalog/positive/multicast-draft01-7-2-6-example.json +0 -1
  97. package/test-vectors/catalog/section-codes.json +8 -3
  98. package/test-vectors/catalog-multicast-auth-v1.json +0 -1
  99. package/test-vectors/catalog-validation.json +3 -3
  100. package/test-vectors/generate-bc-provenance-v2.mjs +94 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockcast/mmt-container",
3
- "version": "1.0.1",
3
+ "version": "2.0.0",
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": "^1.0.1",
173
- "@blockcast/mmt-base": "^1.0.1",
174
- "@blockcast/mmt-fec": "^1.0.1",
175
- "@blockcast/mmt-manifest-verify": "^1.0.1",
172
+ "@blockcast/fec-worker": "^2.0.0",
173
+ "@blockcast/mmt-base": "^2.0.0",
174
+ "@blockcast/mmt-fec": "^2.0.0",
175
+ "@blockcast/mmt-manifest-verify": "^2.0.0",
176
176
  "@noble/curves": "^1.9.7",
177
177
  "@noble/hashes": "^1.8.0",
178
178
  "ajv": "^8.17.1",
@@ -332,19 +332,27 @@
332
332
  "endpointAuth": {
333
333
  "type": "object",
334
334
  "additionalProperties": false,
335
- "required": ["manifestPort", "streamId"],
336
- "description": "draft-ramadan-moq-multicast-01 7.2.6: marks the endpoint's flow protected. Required on every endpoint when multicast.auth is present and forbidden otherwise; port clashes on one (S, G) are validateCmsfMmtpCatalog's.",
335
+ "required": ["streamId"],
336
+ "description": "draft-ramadan-moq-multicast-02 7.2.6 (Blockcast/moqcast-draft@5f49172): marks the endpoint's flow protected; its records ride in band on the endpoint's own port (7.2.3). Required on every endpoint when multicast.auth is present and forbidden otherwise. One endpoint per (S, G, port) and the address-literal rules are multicastCatalogIssues' (a receiver ignores the endpoint).",
337
337
  "properties": {
338
- "manifestPort": { "type": "integer", "minimum": 1, "maximum": 65535 },
339
338
  "streamId": { "type": "integer", "minimum": 0, "maximum": 4294967295 },
340
339
  "senderAddress": {
341
340
  "type": "string",
342
341
  "minLength": 1,
343
342
  "description": "S of a protected ASM endpoint; must not appear on an SSM endpoint."
344
343
  },
345
- "maxAuthDelayMs": { "type": "integer", "minimum": 50, "maximum": 1000, "default": 200 },
346
- "manifestTimerMs": { "type": "integer", "minimum": 1, "maximum": 9007199254740991, "default": 20 },
347
- "manifestTrack": { "type": "string", "minLength": 1 }
344
+ "maxAuthDelayMs": {
345
+ "default": 200,
346
+ "description": "A publisher emits an integer from 50 to 1000. A receiver clamps a number outside that range to the nearer bound and reads a non-number, or an in-range non-integer, as absent (7.2.6), so no value here makes the endpoint ignored."
347
+ },
348
+ "manifestTimerMs": {
349
+ "default": 20,
350
+ "not": { "type": "number", "exclusiveMaximum": 1 },
351
+ "description": "A publisher emits an integer of at least 1. A receiver ignores the endpoint when it is a number below 1 and reads any other non-integer as absent (7.2.6)."
352
+ },
353
+ "manifestTrack": {
354
+ "description": "MoQ track mirroring the records (7.2.5). A receiver reads a non-string as absent (7.2.6)."
355
+ }
348
356
  }
349
357
  },
350
358
  "networkSource": {
@@ -381,7 +389,7 @@
381
389
  "frequency": { "$ref": "#/$defs/positiveInteger", "description": "RF centre frequency in kHz (e.g. 587000 for 587 MHz)." },
382
390
  "plpId": { "type": "integer", "minimum": 0, "maximum": 63 },
383
391
  "serviceId": { "type": "integer", "minimum": 0, "maximum": 65535 },
384
- "slsUri": { "type": "string", "format": "uri", "description": "SLS of an MMTP service, MP table included (A/331:2026-04 7.2). The SLS describes the MMTP session and nothing of this profile, so manifestPort and keys come from provisioning, not from the SLS." }
392
+ "slsUri": { "type": "string", "format": "uri", "description": "SLS of an MMTP service, MP table included (A/331:2026-04 7.2). The SLS describes the MMTP session and nothing of this profile, so the flow parameters and keys come from provisioning, not from the SLS." }
385
393
  }
386
394
  }
387
395
  ]
@@ -13,7 +13,7 @@ import {
13
13
  MMTP_SIGNALING_PACKET_ID,
14
14
  type FecBlockProfile,
15
15
  } from "@blockcast/mmt-base";
16
- import { multicastCatalogIssues, multicastEndpointSource } from "@blockcast/mmt-base";
16
+ import { multicastEndpointSource } from "@blockcast/mmt-base";
17
17
  import type { EndpointAuthConfig, MulticastAuthConfig } from "./multicast-auth-catalog.js";
18
18
 
19
19
  // ── Track Role (Phase 14, D-02..D-05) ─────────────────────────
@@ -304,8 +304,8 @@ export type NetworkSource =
304
304
  serviceId?: number;
305
305
  /**
306
306
  * SLS of the MMTP service, MP table included (A/331:2026-04 7.2). It
307
- * describes the MMTP session and nothing of this profile: manifestPort
308
- * and keys come from provisioning. libmmt does not fetch it.
307
+ * describes the MMTP session and nothing of this profile: the flow
308
+ * parameters and keys come from provisioning. libmmt does not fetch it.
309
309
  */
310
310
  slsUri?: string;
311
311
  };
@@ -676,14 +676,14 @@ function validateNetworkSourceRelays(sources: unknown, path: string): void {
676
676
  }
677
677
 
678
678
  /**
679
- * Validate the common CMSF shape and the MMTP required-field profile.
680
- * `catalogNamespace`, the namespace of the catalog track, sharpens the
681
- * foreign-namespace rule of draft -01 4.1; without it listed tracks are only
682
- * compared with each other.
679
+ * Validate the common CMSF shape and the MMTP required-field profile. It never
680
+ * throws on a draft -02 4.1 / 7.2.6 multicast rule: pass `catalogNamespace`
681
+ * (the namespace of the catalog track) to `admittedMulticastEndpoints` /
682
+ * `multicastCatalogIssues` instead. The option is accepted and unused.
683
683
  */
684
684
  export function validateCmsfMmtpCatalog(
685
685
  value: unknown,
686
- options: { catalogNamespace?: string } = {},
686
+ _options: { catalogNamespace?: string } = {},
687
687
  ): CmsfCatalog {
688
688
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
689
689
  catalogError("catalog_object", "$", "catalog must be an object");
@@ -929,11 +929,12 @@ export function validateCmsfMmtpCatalog(
929
929
  }
930
930
  }
931
931
 
932
- // draft -01 4.1 / 7.2.6: protocol and S, auth all-or-nothing, the key set,
933
- // tuple and manifestPort uniqueness, foreign-namespace tracks. Receivers
934
- // ignore what this rejects (`admittedMulticastEndpoints`).
935
- const [multicastIssue] = multicastCatalogIssues(catalog, options);
936
- if (multicastIssue) catalogError(multicastIssue.code, multicastIssue.path, multicastIssue.message);
932
+ // draft -02 4.1 / 7.2.6 (protocol and S, address literals, auth
933
+ // all-or-nothing, the key set, one endpoint per (S, G, port),
934
+ // foreign-namespace tracks) are never a catalog fault here: a receiver
935
+ // ignores the endpoint an issue names, and a catalog-level issue makes the
936
+ // multicast object yield no data (`admittedMulticastEndpoints`), while MoQ
937
+ // unicast stays usable. `multicastCatalogIssues` reports them.
937
938
 
938
939
  validateArchive(catalog);
939
940
 
@@ -26,6 +26,11 @@ import {
26
26
  serial32Precedes,
27
27
  ssbg1FitsSymbol,
28
28
  type FecBlockProfile,
29
+ admitMmtpRepairObject,
30
+ parseMoqRepairFrameWithHeader,
31
+ type MMTPHeader,
32
+ type MoqRepairFrame,
33
+ type RepairObjectReject,
29
34
  } from '@blockcast/mmt-base'
30
35
  import {
31
36
  IsoBlockLocator,
@@ -131,10 +136,22 @@ export interface FecStats {
131
136
  workers: Array<FecTrackStats & { packetId: number }>
132
137
  /** Main-thread repair symbols rejected before dispatch because their size did not match T. */
133
138
  repairSymbolSizeMismatches: number
139
+ /**
140
+ * Repair objects discarded on admission (draft-ramadan-moq-fec-core-00 7.5,
141
+ * draft-ramadan-moq-mmt-02 7.3), per FEC instance and reason. The instance
142
+ * keeps decoding; other instances are untouched.
143
+ */
144
+ repairObjectRejects: FecRepairObjectRejects[]
134
145
  /** Bounded Worker error counts keyed by stable code, or message text for legacy errors. */
135
146
  workerErrors: Record<string, number>
136
147
  }
137
148
 
149
+ /** One FEC instance's repair-object admission discards, by reason. */
150
+ export type FecRepairObjectRejects = {
151
+ packetId: number
152
+ catalogTrackName: string | undefined
153
+ } & Record<RepairObjectReject, number>
154
+
138
155
  export interface FecPacketStats {
139
156
  packetId: number
140
157
  sourcePackets: number
@@ -514,6 +531,7 @@ export class FECManager {
514
531
  #heldLayerStates = new Map<string, HeldLayerAdmission>()
515
532
  /** Streams whose FEC failed closed at runtime (fec-01 6.3.9). */
516
533
  #fecFailClosed = new Map<string, FecFailClosedReason>()
534
+ #repairObjectRejects = new Map<string, FecRepairObjectRejects>()
517
535
  #onFecFailClosed: ((event: FecFailClosedEvent) => void) | null = null
518
536
  // Blocks already recovered — prevents late source symbols from restarting timeouts.
519
537
  // WASM removes blocks after recovery; late symbols create empty blocks returning null,
@@ -2395,6 +2413,7 @@ export class FECManager {
2395
2413
  packetId,
2396
2414
  })),
2397
2415
  repairSymbolSizeMismatches: this.#repairSymbolSizeMismatchCount,
2416
+ repairObjectRejects: [...this.#repairObjectRejects.values()].map((r) => ({ ...r })),
2398
2417
  workerErrors: Object.fromEntries(this.#workerErrorCounts),
2399
2418
  }
2400
2419
  }
@@ -4140,15 +4159,7 @@ export class FECManager {
4140
4159
  ): boolean {
4141
4160
  const streamKey = this.#streamKey(packetId, catalogTrackName)
4142
4161
  if (this.#fecFailClosed.has(streamKey)) return false
4143
- const config = this.#trackConfigs.get(packetId)
4144
- const layers = config?.repairLayers
4145
- if (layers === undefined || layers.length === 0) return true
4146
- let admission = this.#heldLayerStates.get(streamKey)
4147
- if (admission === undefined) {
4148
- admission = new HeldLayerAdmission(layers, config!.heldLayers)
4149
- this.#heldLayerStates.set(streamKey, admission)
4150
- }
4151
- const verdict = admission.admit(
4162
+ const verdict = this.#heldLayerAdmission(packetId, streamKey)?.admit(
4152
4163
  repairTrackName,
4153
4164
  ssStart,
4154
4165
  rsbLength,
@@ -4157,16 +4168,80 @@ export class FECManager {
4157
4168
  )
4158
4169
  if (verdict === 'discard') return false
4159
4170
  if (verdict !== undefined) {
4160
- this.#fecFailClosed.set(streamKey, verdict)
4161
- this.#onFecFailClosed?.({ ...verdict, packetId, catalogTrackName })
4162
- console.warn(
4163
- `[FECManager] FEC failed closed (fec-01 6.3.9) packetId=${packetId} track=${catalogTrackName ?? '-'}: ${JSON.stringify(verdict)}`,
4164
- )
4171
+ this.#failClosed(streamKey, verdict, packetId, catalogTrackName)
4165
4172
  return false
4166
4173
  }
4167
4174
  return true
4168
4175
  }
4169
4176
 
4177
+ #heldLayerAdmission(packetId: number, streamKey: string): HeldLayerAdmission | undefined {
4178
+ const config = this.#trackConfigs.get(packetId)
4179
+ const layers = config?.repairLayers
4180
+ if (layers === undefined || layers.length === 0) return undefined
4181
+ let admission = this.#heldLayerStates.get(streamKey)
4182
+ if (admission === undefined) {
4183
+ admission = new HeldLayerAdmission(layers, config!.heldLayers)
4184
+ this.#heldLayerStates.set(streamKey, admission)
4185
+ }
4186
+ return admission
4187
+ }
4188
+
4189
+ #failClosed(streamKey: string, verdict: FecFailClosedReason, packetId: number, catalogTrackName: string | undefined): void {
4190
+ this.#fecFailClosed.set(streamKey, verdict)
4191
+ this.#onFecFailClosed?.({ ...verdict, packetId, catalogTrackName })
4192
+ console.warn(
4193
+ `[FECManager] FEC failed closed (fec-01 6.3.9) packetId=${packetId} track=${catalogTrackName ?? '-'}: ${JSON.stringify(verdict)}`,
4194
+ )
4195
+ }
4196
+
4197
+ /**
4198
+ * Admission of one iso-ssbg1-v1 repair object of `packetId`'s instance,
4199
+ * before it takes any part in block state (draft-ramadan-moq-fec-core-00
4200
+ * 7.5, draft-ramadan-moq-mmt-02 7.3): exactly 12 + 13 + T bytes, RS_ID below
4201
+ * RSB_length under every scheme, and the scheme's bound. The instance is the
4202
+ * one the repair track names (`packetId`, `catalogTrackName`), never one
4203
+ * inferred from the fields. A discard is counted on `repairObjectRejects`
4204
+ * and the instance keeps decoding; except that an object the RS_ID or
4205
+ * scheme discard would take whose RSB_length is below a held layer's
4206
+ * O_i + P_i on that layer's track or on an anchored block stops the
4207
+ * instance's FEC instead (core-00 7.3.2). Returns the frame to admit.
4208
+ */
4209
+ admitRepairObject(
4210
+ packetId: number,
4211
+ catalogTrackName: string | undefined,
4212
+ repairTrackName: string | undefined,
4213
+ packet: Uint8Array,
4214
+ header: MMTPHeader,
4215
+ ): MoqRepairFrame | null {
4216
+ const config = this.#trackConfigs.get(packetId)
4217
+ // No T to measure against: the parse alone (the size check refuses later).
4218
+ if (!config || !(config.symbolSize > 0)) return parseMoqRepairFrameWithHeader(packet, header)
4219
+ const admission = admitMmtpRepairObject(packet, header, config.symbolSize, config.algorithm ?? 'raptorq')
4220
+ if (!admission) return null
4221
+ if ('frame' in admission && admission.reject === undefined) return admission.frame
4222
+ const streamKey = this.#streamKey(packetId, catalogTrackName)
4223
+ if ('frame' in admission && !this.#fecFailClosed.has(streamKey)) {
4224
+ const verdict = this.#heldLayerAdmission(packetId, streamKey)?.failsClosed(
4225
+ repairTrackName,
4226
+ admission.frame.ssStart,
4227
+ admission.frame.rsbLength,
4228
+ this.#layerAnchorHorizon(packetId),
4229
+ this.#sourceFrontiers.get(streamKey)?.sbn,
4230
+ )
4231
+ if (verdict) {
4232
+ this.#failClosed(streamKey, verdict, packetId, catalogTrackName)
4233
+ return null
4234
+ }
4235
+ }
4236
+ let rejects = this.#repairObjectRejects.get(streamKey)
4237
+ if (rejects === undefined) {
4238
+ rejects = { packetId, catalogTrackName, repairLength: 0, repairRsId: 0, repairSchemeBound: 0 }
4239
+ this.#repairObjectRejects.set(streamKey, rejects)
4240
+ }
4241
+ rejects[admission.reject!]++
4242
+ return null
4243
+ }
4244
+
4170
4245
  /**
4171
4246
  * How far (in SS_Start symbols) behind the newest block a 6.3.9 anchor or
4172
4247
  * below-total block can still matter: the iso locator's retention in blocks
@@ -99,6 +99,17 @@ export class HeldLayerAdmission {
99
99
  rsbLength: number,
100
100
  horizon: number,
101
101
  sourceFrontier?: number,
102
+ ): FecFailClosedReason | 'discard' | undefined {
103
+ return this.#admit(repairTrackName, ssStart, rsbLength, horizon, sourceFrontier, true)
104
+ }
105
+
106
+ #admit(
107
+ repairTrackName: string | undefined,
108
+ ssStart: number,
109
+ rsbLength: number,
110
+ horizon: number,
111
+ sourceFrontier: number | undefined,
112
+ retroactive: boolean,
102
113
  ): FecFailClosedReason | 'discard' | undefined {
103
114
  if (repairTrackName !== undefined && !this.#layerNames.has(repairTrackName)) return 'discard'
104
115
  // A repair may run ahead of the media, but one more than `horizon` past the
@@ -142,7 +153,7 @@ export class HeldLayerAdmission {
142
153
  }
143
154
  } else if (!state.settled && !stale(ssStart)) {
144
155
  if (state.anchor === undefined || serial32Precedes(ssStart, state.anchor)) state.anchor = ssStart
145
- if (state.latestBelow !== undefined && !serial32Precedes(state.latestBelow, state.anchor)) {
156
+ if (retroactive && state.latestBelow !== undefined && !serial32Precedes(state.latestBelow, state.anchor)) {
146
157
  code = 'below_anchored_total'
147
158
  ssStart = state.latestBelow
148
159
  rsbLength = state.latestBelowRsb!
@@ -158,4 +169,30 @@ export class HeldLayerAdmission {
158
169
  }
159
170
  return undefined
160
171
  }
172
+
173
+ /**
174
+ * The core-00 7.5 carve-out for a repair object about to be discarded
175
+ * (RS_ID or scheme bound): whether its own RSB_length, below a held layer's
176
+ * total on that layer's track or on an anchored block, fails the instance
177
+ * closed under 7.3.2. Leaves the anchored state as it was: a discarded
178
+ * object anchors nothing.
179
+ */
180
+ failsClosed(
181
+ repairTrackName: string | undefined,
182
+ ssStart: number,
183
+ rsbLength: number,
184
+ horizon: number,
185
+ sourceFrontier?: number,
186
+ ): FecFailClosedReason | undefined {
187
+ const saved = this.#states.map((state) => ({ ...state }))
188
+ try {
189
+ // No retroactive verdict: a discarded object anchors nothing, so it
190
+ // cannot fail an earlier below-total block, and every held layer is
191
+ // still judged on the object's own RSB_length.
192
+ const verdict = this.#admit(repairTrackName, ssStart, rsbLength, horizon, sourceFrontier, false)
193
+ return typeof verdict === 'object' ? verdict : undefined
194
+ } finally {
195
+ saved.forEach((state, i) => { this.#states[i] = state })
196
+ }
197
+ }
161
198
  }
@@ -29,11 +29,11 @@
29
29
  */
30
30
 
31
31
  import {
32
+ admitMmtpRepairObject,
32
33
  isoBlockContains,
33
34
  isoBlocksBehindLowerBound,
34
35
  parseFecBlockProfile,
35
36
  parseMMTPHeader,
36
- parseMoqRepairFrameWithHeader,
37
37
  serial32Add,
38
38
  serial32Distance,
39
39
  serial32Precedes,
@@ -374,20 +374,28 @@ export class MmtpFecSession {
374
374
  } catch {
375
375
  return this.#drop('repair:malformed')
376
376
  }
377
- const frame = parseMoqRepairFrameWithHeader(packet, header)
378
- if (!frame) return this.#drop('repair:malformed')
379
- if (this.#repairPacketId === undefined) {
380
- this.#repairPacketId = frame.packetId
381
- } else if (frame.packetId !== this.#repairPacketId) {
377
+ // core-00 7.5 / mmt-02 7.3: exactly 12 + 13 + T, judged before any field.
378
+ const admission = admitMmtpRepairObject(packet, header, this.symbolSize, this.algorithm)
379
+ if (!admission) return this.#drop('repair:malformed')
380
+ if (!('frame' in admission)) return this.#drop('repair:length')
381
+ const frame = admission.frame
382
+ if (this.#repairPacketId !== undefined && frame.packetId !== this.#repairPacketId) {
382
383
  return this.#drop('repair:foreign-packet-id')
383
384
  }
384
- if (frame.symbolData.byteLength !== this.symbolSize) return this.#drop('repair:symbol-size')
385
- if (frame.ssbLength > this.sourceSymbols) return this.#drop('repair:ssb-length')
386
- // fec-01 4.5: a reed-solomon block holds at most 255 symbols. Discarded
387
- // before any block bookkeeping, so the block state is unchanged.
388
- if (this.algorithm === 'reed-solomon' && frame.ssbLength + frame.rsbLength > 255) {
389
- return this.#drop('repair:rs-block-length')
385
+ if (admission.reject) {
386
+ // An RSB_length below a held layer's total goes to the 7.3.2 stop, not
387
+ // to the discard; the block state is unchanged either way.
388
+ const verdict = this.#heldLayers.failsClosed(
389
+ repairTrackName, frame.ssStart, frame.rsbLength, this.#anchorHorizon, this.#newestSource,
390
+ )
391
+ if (verdict) {
392
+ this.#stats.failClosed = verdict
393
+ return this.#drop('repair:fail-closed')
394
+ }
395
+ return this.#drop(admission.reject === 'repairRsId' ? 'repair:rs-id' : 'repair:scheme-bound')
390
396
  }
397
+ this.#repairPacketId ??= frame.packetId
398
+ if (frame.ssbLength > this.sourceSymbols) return this.#drop('repair:ssb-length')
391
399
  // fec-01 6.3.9, after the 4.5 discards and the size check, before a block
392
400
  // is placed. Without a track name only the anchored rule (b) applies.
393
401
  const verdict = this.#heldLayers.admit(
@@ -21,6 +21,7 @@ import {
21
21
  serial32Precedes,
22
22
  type FecBlockProfile,
23
23
  type MMTPHeader,
24
+ type MoqRepairFrame,
24
25
  } from "@blockcast/mmt-base";
25
26
  import type { IsoHeldSource, WasmFecDecoder } from "./fec-manager.js";
26
27
  import type { IsoPlacedSource, IsoRepairPlacement } from "./iso-block-locator.js";
@@ -66,9 +67,10 @@ import {
66
67
  StreamDuplicateLedgers,
67
68
  type DuplicateHorizon,
68
69
  } from "./duplicate-ledger.js";
69
- import type {
70
- BcProvenanceV2Receiver,
71
- BcpV2RecordPath,
70
+ import {
71
+ isBcpV2RecordCandidate,
72
+ type BcProvenanceV2Receiver,
73
+ type BcpV2RecordPath,
72
74
  } from "@blockcast/mmt-manifest-verify";
73
75
 
74
76
  // ─── Constants ─────────────────────────────────────────────────────
@@ -176,9 +178,10 @@ export type RoutedFrameMeta = {
176
178
  export interface MmtpIngressContext {
177
179
  tracks: readonly Readonly<{ name: string; packetId: number }>[];
178
180
  /**
179
- * The bc-provenance v2 flow (draft-ramadan-moq-multicast-01 7.2.7) that
181
+ * The bc-provenance v2 flow (draft-ramadan-moq-multicast-02 7.2.7) that
180
182
  * delivered the packet, when its endpoint is protected: the key a receiver
181
- * was registered under with `registerProvenanceFlow`.
183
+ * was registered under with `registerProvenanceFlow`. Its records arrive on
184
+ * the same ingress and `feedPacket` splits them off.
182
185
  */
183
186
  flow?: string;
184
187
  }
@@ -597,6 +600,15 @@ export interface FECManagerLike {
597
600
  /** fec-01 4.5: refuse a repair header whose block length the track's scheme
598
601
  * forbids, before it touches block state (optional). */
599
602
  admitRepairBlockLength?(packetId: number, ssbLength: number, rsbLength: number, rsId: number): boolean;
603
+ /** core-00 7.5 / mmt-02 7.3 admission of one repair object of the named
604
+ * instance; counts a discard per instance (optional). */
605
+ admitRepairObject?(
606
+ packetId: number,
607
+ catalogTrackName: string | undefined,
608
+ repairTrackName: string | undefined,
609
+ packet: Uint8Array,
610
+ header: MMTPHeader,
611
+ ): MoqRepairFrame | null;
600
612
  admitRepairLayerTotal?(
601
613
  packetId: number,
602
614
  catalogTrackName: string | undefined,
@@ -2151,8 +2163,9 @@ export class MmtpRouter {
2151
2163
  }
2152
2164
 
2153
2165
  /**
2154
- * Feed one bc-provenance record of `flow`: a datagram from `manifestPort`
2155
- * (`multicast`) or an object of the manifest track (`mirror`). Held
2166
+ * Feed one bc-provenance record of `flow`: a datagram on the flow's own
2167
+ * `port` (`multicast`; `feedPacket` splits these off on "MHM2",
2168
+ * multicast-02 7.2.7) or an object of the manifest track (`mirror`). Held
2156
2169
  * datagrams the record admits are routed before this returns.
2157
2170
  */
2158
2171
  feedProvenanceRecord(flow: string, record: Uint8Array, path: BcpV2RecordPath): string {
@@ -2218,6 +2231,16 @@ export class MmtpRouter {
2218
2231
  ingress?: MmtpIngressContext,
2219
2232
  ): void {
2220
2233
  if (this.#disposed) return;
2234
+ // multicast-02 7.2.7: on a protected flow's `port`, a datagram whose first
2235
+ // four octets are "MHM2" is a record candidate. The split precedes every
2236
+ // other read: a candidate is never parsed as MMTP, held, hashed as data,
2237
+ // sampled as an arrival or counted by packet_id, and one that fails step 1
2238
+ // is dropped there.
2239
+ if (source === "multicast" && ingress?.flow !== undefined && isBcpV2RecordCandidate(packet)) {
2240
+ if (this.#provenance.has(ingress.flow)) this.feedProvenanceRecord(ingress.flow, packet, "multicast");
2241
+ else this.#unregisteredFlowDrops++;
2242
+ return;
2243
+ }
2221
2244
  this.#withDeferredFecPokes(() => {
2222
2245
  this.#processMMTP(packet, source, false, ingress);
2223
2246
  });
@@ -2290,9 +2313,6 @@ export class MmtpRouter {
2290
2313
  } catch {
2291
2314
  return;
2292
2315
  }
2293
- const parsed = parseMoqRepairFrameWithHeader(packet, header);
2294
- if (!parsed) return;
2295
-
2296
2316
  // A dedicated repair track can carry a packet ID that differs from the
2297
2317
  // media packet ID. The subscribing track supplies the authoritative owner.
2298
2318
  const repairPacketId = header.packetId;
@@ -2310,18 +2330,24 @@ export class MmtpRouter {
2310
2330
  `[MmtpRouter] Dropping MoQ repair packet pid=${repairPacketId}: owner hint is required`,
2311
2331
  );
2312
2332
  }
2313
-
2333
+ // The frame is read only once 7.2.7 admits the datagram: admission
2334
+ // (core-00 7.5) counts rejects and can fail the instance closed. A
2335
+ // malformed held datagram is judged, and counted, when a record admits it.
2314
2336
  // Admission keys on the repair track's own packet id: a repair layer no
2315
2337
  // protected flow lists is an unlisted track (7.2.7), not a datagram of the
2316
2338
  // flow that lists its owner, where it would be held until expiry. The
2317
2339
  // flow listing it governs the owner too: a repair flow also lists its
2318
2340
  // owners' source tracks (#provenanceFor).
2341
+ let unlisted = false;
2319
2342
  if (ownerPacketId != null && !admitted) {
2320
2343
  const admit = this.#admitUnicast(repairPacketId, packet, () =>
2321
2344
  this.#feedRepairPacket(packet, ownerPacketIdHint, catalogTrackName, repairTrackName, true));
2322
2345
  if (admit === "held" || admit === "dropped") return;
2323
- if (admit === "unlisted" && this.#fecManager && this.#setAsideUnlistedRepair(ownerPacketId, catalogTrackName, parsed)) return;
2346
+ unlisted = admit === "unlisted";
2324
2347
  }
2348
+ const parsed = this.#readRepairObject(ownerPacketId, catalogTrackName, repairTrackName, packet, header);
2349
+ if (!parsed) return;
2350
+ if (unlisted && this.#fecManager && this.#setAsideUnlistedRepair(ownerPacketId!, catalogTrackName, parsed)) return;
2325
2351
  if (ownerPacketId != null && this.#fecManager) {
2326
2352
  this.#moqRepairPidCounts[repairPacketId] =
2327
2353
  (this.#moqRepairPidCounts[repairPacketId] || 0) + 1;
@@ -2432,6 +2458,25 @@ export class MmtpRouter {
2432
2458
  return serial32Add(blockStart, index);
2433
2459
  }
2434
2460
 
2461
+ /**
2462
+ * A repair packet's frame, admitted under its owner instance's T and scheme
2463
+ * when that instance is configured (draft-ramadan-moq-fec-core-00 7.5,
2464
+ * draft-ramadan-moq-mmt-02 7.3); otherwise only parsed.
2465
+ */
2466
+ #readRepairObject(
2467
+ ownerPacketId: number | null,
2468
+ catalogTrackName: string | undefined,
2469
+ repairTrackName: string | undefined,
2470
+ packet: Uint8Array,
2471
+ header: MMTPHeader,
2472
+ ): MoqRepairFrame | null {
2473
+ const fec = this.#fecManager;
2474
+ if (ownerPacketId != null && fec?.admitRepairObject && this.#configuredBlockProfile(ownerPacketId) !== undefined) {
2475
+ return fec.admitRepairObject(ownerPacketId, catalogTrackName, repairTrackName, packet, header);
2476
+ }
2477
+ return parseMoqRepairFrameWithHeader(packet, header);
2478
+ }
2479
+
2435
2480
  /**
2436
2481
  * One iso-ssbg1-v1 repair symbol (ISO/IEC 23008-1 C.5.3). It states the
2437
2482
  * block `[SS_Start, SS_Start + SSB_length)`; repair ESI = SSB_length +
@@ -3505,12 +3550,16 @@ export class MmtpRouter {
3505
3550
  // Repair packet — per ISO 23008-1 §C.5.3, carries (SS_Start, SSB_length, RS_ID).
3506
3551
  // Read from the header parsed above rather than parsing it again
3507
3552
  // (BLO-36599).
3508
- const repair = parseMoqRepairFrameWithHeader(packet, parsed.header);
3509
- if (!repair) return;
3510
-
3511
3553
  const ownerPacketId = this.#resolveRepairOwnerPacketId(info.packetId, ingress);
3554
+ // An ambiguous name attributes the packet to no instance, so it is
3555
+ // dropped before admission can count or fail closed against one.
3556
+ if (ownerPacketId != null && this.#fecManager && hasAmbiguousCatalogTrackName(ingress, ownerPacketId)) return;
3557
+ const repair = this.#readRepairObject(
3558
+ ownerPacketId, ownerPacketId == null ? undefined : catalogTrackNameForPacket(ingress, ownerPacketId),
3559
+ undefined, packet, parsed.header,
3560
+ );
3561
+ if (!repair) return;
3512
3562
  if (ownerPacketId != null && this.#fecManager) {
3513
- if (hasAmbiguousCatalogTrackName(ingress, ownerPacketId)) return;
3514
3563
  // An unadmitted MoQ symbol reaching here is unlisted (#admitUnicast).
3515
3564
  if (
3516
3565
  source === "moq" && !manifestAdmitted &&