multimux 0.10.0

Multi-input (RTSP/RTP/TS-UDP/TS-HTTP/SRT/HLS-pull/DASH-pull/Smooth-pull/RTMP/file), multi-output (LL-HLS/DASH/LL-DASH/Smooth/TS-HLS/catch-up + SRT/RTMP/RTSP push) just-in-time repackaging HTTP origin (library: tokio + axum), with shared output auth and an external scheme plugin registry.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
# Changelog

## [Unreleased]

## [0.10.0] - 2026-08-14

### Fixed

- **Crate description and README understated the output set.** Both advertised
  `LL-HLS/DASH/LL-DASH` while `OutputKind` has shipped `Smooth`, `TsHls`,
  `Catchup`, `SrtPush`, `RtmpPush`, `RtspPush` and `Custom` for several
  releases; the 0.10.0 release note already listed TS-HLS, so the crates.io
  description was the surface that disagreed. Corrected everywhere the set is
  named (crate description, workspace README, `multimux-cli`'s description and
  README).
### Added
- **`InputSpec::File { path, loop }` — stream a media file as a route input**
  (issue #748). Point a route at a file on disk and it is served through the
  same `supervise_driver` + `advance_route` path as every other source, so the
  existing outputs (LL-HLS, DASH, LL-DASH, TS-HLS) work unchanged:

  ```json
  { "name": "slate",
    "input": { "type": "file", "path": "/media/slate.ts", "loop": true },
    "outputs": ["llhls"] }
  ```

  The container is identified with the new `container-probe` crate over the
  whole file, and the verdict selects the demuxer — including the ISOBMFF
  fragmented/progressive split via `IsobmffLayout`, so no box-walking is
  duplicated here. A format `transmux` cannot demux, and every ambiguous or
  undetermined verdict, fails with its own structured error: **a file is never
  fed to a guessed demuxer.**

  `loop` defaults to `true`. Looping refills from the already-parsed content
  with a per-track PTS offset so the output timeline stays strictly monotonic
  across the loop point. That offset derives from the **presentation-max** PTS
  rather than the decode-order last sample — a track with B-frames reorders, so
  a decode-order basis would step backwards across the loop.

  Samples are paced to wall clock on both the standalone reader and the route
  path, in every `pace` x `loop` combination, including across the loop
  boundary. Unpaced, the route republished the whole file every 10 ms (~300x
  realtime), leaving the playlist's segment cadence meaningless to a player.

  **Known limit:** a long asset holds its parsed samples in memory — the file is
  demuxed in one pass and the sample ring is sized to it. Suited to slates and
  short assets; a long-file path would need incremental demux.

### Note on scope

Issue #748 originally designed a linear-playout **channel** — scheduled
switching between sources, a rebased shared timeline, and SCTE-35 ad-break
signalling. **That was cancelled during implementation** and its code reverted
rather than left half-finished; the complexity was not earning its keep. Only
the file-input capability above shipped. See
`docs/superpowers/specs/2026-08-11-linear-playout-design.md`, marked
`SUPERSEDED`, for what was dropped and why.

(An earlier draft of this entry claimed the cancelled controller's dependency on
`playout-runtime`/`ssai-runtime` would have blocked publishing `multimux`
because neither was on crates.io. That was wrong — both are live at 0.1.0. The
check had queried crates.io without a `User-Agent`, which the API answers with
HTTP 403, and the rejection was misread as absence. The cancellation stands on
its own merits; the claim does not.)

## [0.9.0] - 2026-08-11

### Added
- **Catch-up / time-shift / VOD-from-live serving over the DVR archive**
  (issue #900, follow-up to #746/#903): a new `"catchup"` output
  (`OutputKind::Catchup`) serves `GET /catchup.m3u8` (optionally
  `?window_secs=N` to bound the trailing catch-up window),
  `GET /vod/p{N}.m3u8` (one archived period rendered as a complete,
  `#EXT-X-ENDLIST`/`PLAYLIST-TYPE:VOD` asset once a later period exists on
  disk proving it finished), and `GET /catchup/seg-{seq}.{ext}` (the
  resource route both playlists reference). Requires
  `routes.dvr.enabled: true`; rejected otherwise by `Config::validate`.
  Shares the same output auth (Basic/Digest/Bearer/Forwarded) every other
  output gets — no separate auth path.
  - **The straddle boundary** (the reason this exists): the DVR archive and
    the live `Trunk` are different sources sharing one sequence-number
    space. `catchup.m3u8` merges the archive's segments with only the live
    `Trunk`'s still-unarchived tail (`crate::catchup::merge_segments`),
    producing one continuous, gap-free, duplicate-free playlist across the
    boundary — never two disjoint lists a client has to stitch together.
    A request for an archived segment reads its exact byte range off disk;
    a request for the unarchived tail resolves through the same
    `HlsOrigin` the live outputs already share. No second in-memory cache
    of live data is built — per #746's hard constraint, the archive is
    read fresh from disk per request, and the live tail is read through
    the existing `HlsOrigin` cursor (`HlsOrigin::closed_segments`, new)
    rather than a second one.
  - `crate::dvr::IndexEntry` gained `duration_ns`/`discontinuous` fields
    (`#[serde(default)]`, so an old `pN.idx` still parses) — needed to
    render an archived segment's `#EXTINF`/`#EXT-X-DISCONTINUITY` without
    decoding its media bytes.
  - `hls_runtime::server::HlsOrigin` gained `closed_segments()` (returning
    the new public `hls_runtime::server::ClosedSegment`), reusing its
    existing live-window cursor rather than requiring a caller to open a
    second one on the same `Trunk`.
- **Programme-aligned DVR rolling** (issue #903, follow-up to #746): a DVR
  route can now opt in to rolling its archive period on the DVB EIT
  present/following transition (ETSI EN 300 468 §5.2.4) instead of only on
  the clock, so one recording is one programme rather than an arbitrary
  time slice. `DvrConfig::dvb_service_id` names the service to track;
  `DvrRecorder::feed_si` (fed raw TS bytes by `source::ts_udp`/
  `source::ts_http`/`source::srt` via the new
  `RouteHandle::feed_si_ts`/`ProgramServing::feed_si`) reassembles the
  service's EIT p/f actual section and rolls the period the moment the
  present `event_id` changes. Each period gets a `pN.event.json` sidecar
  (`event_id`, `service_id`, title, announced start/duration) alongside its
  `pN.<ext>`/`pN.idx`, so an operator can find a programme rather than a
  timestamp. The existing `period_duration_secs` clock stays active
  unconditionally as both the fallback (routes/sources with no SI) and the
  hard cap (an EPG whose EIT carousel never signals a transition) — EIT
  alignment only ever shortens a period, never removes the cap.
- **WHEP egress output** (`OutputKind::Whep`, config token `"whep"`, issue
  #743): accepts a viewer's HTTP `POST`ed SDP offer, negotiates ICE +
  DTLS-SRTP, and pushes the route's `Trunk` samples out as SRTP RTP over
  `transmux::RtpPacketiser::packetise_video` (RFC 6184 single-NAL/STAP-A/
  FU-A). A sibling `whep` Cargo feature to `whip` (below) — both pull in
  `webrtc-runtime/media` and share its rustc >= 1.88 floor, kept optional so
  an HTTP-only consumer isn't forced to build the ICE/DTLS-SRTP tree. Scope:
  video (H.264) only, no trickle ICE, no `PATCH`. Verified against a real
  second `RTCPeerConnection` (not a mock peer): 119 decoded H.264 frames,
  `<video>` `currentTime` advancing to ~6.13s, reproduced across two
  independent runs.
- **WHIP ingest** (`InputSpec::Whip`, config token `"whip"`, issues #740/
  #743): an inbound WHIP publisher (RFC 9725) — HTTP `POST`/SDP-offer
  signalling plus `webrtc-runtime`'s `MediaTransport` for ICE + DTLS-SRTP —
  behind a new `whip` feature. Real `avcC` config is captured from the
  in-band STAP-A SPS/PPS (browsers omit it from SDP) rather than fabricated,
  using the same deferred-announce gate `source::rtmp` already uses for
  "wait for the first sample". Video-only (H.264); no RTP/Opus depayloader
  exists anywhere in this workspace, so audio is out of scope rather than
  half-built. Verified against a real browser (headless Chromium, fake video
  device, forced H.264) over a real UDP socket and multimux's own generated
  certificate.
  - **Push egress converged onto the same sans-IO `PushEgress` shape as
    WHIP/WHEP** (issue #942): `PushTransport` gained `encode_media`
    (sans-IO encode) and `write_message` (raw verbatim write, distinct from
    the framing `send`), so `PushTransportEgress<T>` can implement
    `PushEgress``send()` encodes and queues, `flush_transmit()` writes.
    RTMP's ad-hoc `is_flv_codec`/`warned_refused_tracks` refusal logic now
    lives in `supports_codec` + `negotiate`/`renegotiate` returning
    `NegotiationOutcome::Refused`; `drive_push` also detects a mid-stream
    track-set change via `Trunk::track_generation` and renegotiates, which
    it previously never handled.

### Changed (BREAKING — issue #903, EIT-boundary DVR rolling)
- `InputSpec::Custom` and `OutputKind::Catchup` are **additive**. Both enums
  were already `#[non_exhaustive]` at the `multimux-v0.8.0` tag
  (`git show multimux-v0.8.0:multimux/src/config.rs`, line 75 —
  `#[non_exhaustive]` immediately above `pub enum InputSpec`; and
  `.../src/output/mod.rs` line 50 for `OutputKind`), so no external
  exhaustive `match` over either compiled at 0.8.0, and none breaks now. Two
  successive pre-release audits recorded one of these as a breaking
  `#[non_exhaustive]` *addition* — both were wrong, in opposite directions.
  This entry replaces them, and would have sent every consumer on a migration
  for a break that never happened.
- **`source::ts_udp::recv_and_feed` now returns `Result<usize>`** (was
  `Result<()>`): the number of bytes read, so a caller can also feed the
  exact same slice to the new `RouteHandle::feed_si_ts` EIT tracker without
  a second read. Source-breaking for any direct caller of this `pub` async
  function.
- **`source::srt::StreamStatus::Fed` and `source::ts_http::StreamStatus::Fed`
  now carry the payload (`Fed(Vec<u8>)`)** (were the unit variant `Fed`),
  for the same reason — feeding `RouteHandle::feed_si_ts` from the drive loop
  without a second read. Both `StreamStatus` enums are `pub`; a direct match
  on either no longer compiles without a binding on `Fed`.

### Changed
- **`broadcast-common` floor raised `9` -> `9.3`.** `output::smooth` calls
  `broadcast_common::hex::hex_encode`, absent at the 9.0.0/9.1.0 tags
  (verified against all three: 9.0.0 -> 0 hits, 9.1.0 -> 0, 9.2.0 -> 1).
  multimux 0.8.0 shipped this understated, so a lock resolving
  broadcast-common 9.0/9.1 fails to build published 0.8.0 today. The API
  reason bounds it at 9.2; the declared floor is 9.3 because the MSRV-1.95
  wave (#949) moved broadcast-common there in the same release.
- **`transmux` floor raised `0.23` -> `0.24`.** `push::rtmp` calls
  `transmux::flv_sequence_header_payloads`/`flv_frame_payloads`, absent at
  transmux-v0.23.0 and present at v0.23.1. Same understated-floor class as
  the broadcast-common entry above, found by the same audit. The API reason
  bounds it at 0.23.1; the declared floor is 0.24, the epoch this release
  builds against (a caret bucket spanning two epochs breaks consumers of
  both lines — #858).
- **`hls-runtime` floor raised `0.5` -> `0.6`.** `src/catchup.rs` uses
  `hls_runtime::server::ClosedSegment`/`HlsOrigin::closed_segments()`, which
  do not exist in published hls-runtime 0.5.0 (verified: 0 hits at the
  hls-runtime-v0.5.0 tag, 7 at HEAD). multimux declared `"0.5"`, so a
  consumer resolving 0.5.0 would fail to compile multimux; hls-runtime's own
  0.5.0 -> 0.6.0 minor (new public API) ships alongside this fix.
- **`broadcast-auth` floor raised `0.2` -> `0.3`.** `config.rs` uses
  `Verifier::signed_url` / `SignedUrlKeySet`, which were added in 0.2.1
  (#747). The `"0.2"` bound admitted 0.2.0, which does not have them, so
  multimux 0.8.0 shipped declaring a requirement weaker than its real one and
  fails to build against any lock that resolves 0.2.0. Hit for real while
  bumping `acap-multimux`, where the lock picked the floor. The API reason
  bounds it at 0.2.1; the declared floor is 0.3, the epoch this release
  builds against.
- MSRV raised to **1.95.0** (issue #949). This removes the workspace's MSRV
  split: `webrtc-runtime`'s optional `media` feature needed rustc 1.88 (via
  `rcgen`), which had grown a dedicated CI job, six `--exclude` lanes and a
  guard script to contain. Adopting let-chains and `is_multiple_of` where the
  1.95 lints require them; no functional or API change.
### Fixed

- **One authenticated `POST /admin/routes` could permanently disable the admin
  API** (requires the `whep` feature). `spawn_route` filtered a route's outputs
  by `!k.is_push()` alone, where the startup path filters
  `!k.is_push() && !k.is_whep()`. A `whep` output therefore reached
  `build_output`, which is `unreachable!()` for it — WHEP egress is driven by a
  raw listen socket, not an `Arc<dyn Output>` — and `validate_standalone` does
  not reject such a route, since it only checks the listen address.

  The panic is not the damage; where it panics is. `add_route` holds
  `self.inner.write()` across `spawn_route`, so the unwind **poisoned the
  registry lock**, and every later admin operation and router rebuild calls
  `.expect("RouteRegistry::inner lock poisoned")`. One request disabled the
  admin API for the remaining lifetime of the process. The `reload` path calls
  `spawn_route` without the lock and so lost only that one request.

  A one-word divergence between two filters that had to agree. The regression
  test asserts the registry is still usable afterwards — poisoning is what it
  destroyed, so a test that only asserted "no panic" would not have caught it.

- **A wrong RTSP password retried forever instead of failing the route**
  (issue #957, found end-to-end against a real Axis camera). `origin::
  supervisor::supervise_driver` treated every ingest failure as transient,
  including `MultimuxError::Auth` (a `401`/`403` that persisted after
  credentials were supplied) — a wrong password is never going to start
  working, so it just produced an endless warn-level reconnect loop. Auth
  failures (and a `404 Not Found` specifically on RTSP DESCRIBE, a wrong
  URL path — also never self-healing) now stop the supervisor outright and
  mark the route `HealthState::Failed`, surfaced by the admin API's route
  status without reading logs. A camera still finishing its boot can
  transiently answer `401` before its auth subsystem is ready, so auth
  failures are tolerated for a bounded number of consecutive attempts
  (`MAX_AUTH_ATTEMPTS_BEFORE_PERMANENT`, 5, chosen against the default
  backoff schedule's ~15.5s cumulative delay by attempt 5) before being
  declared permanent — every other failure kind keeps the existing
  supervised reconnect, unchanged.
- **`catchup::read_archived_bytes` allocated whatever a corrupt `pN.idx`
  sidecar claimed.** `byte_len` comes from a DVR-archive `IndexEntry` in a
  JSON sidecar on disk; `read_exact` caught a truncated container, but
  nothing bounded `byte_len` against the period file's real size *before*
  `vec![0u8; byte_len as usize]` allocated for it — a corrupt-but-JSON-valid
  sidecar (the same power-loss/full-disk class the DVR index-rebuild fix
  above addresses) meant an unbounded allocation. Now bound against
  `File::metadata().len()` (with a `checked_add` guard on `offset + len`
  too) before allocating, returning an error instead.
- **One unauthenticated datagram could tear down a live WHIP ingest.**
  `handle_datagram` returns `Err` on any SRTP authentication failure, and the
  WHIP read loop mapped that to a fatal transport error, reaping the session
  — but the post-handshake media socket accepts datagrams from anyone (no
  source-address/ICE-pair check), so a single garbage datagram in the RFC
  5764 §5.1.2 SRTP band ended an established ingest. Now logged at debug and
  skipped, matching how `output::whep` already handled the identical error;
  a genuine socket-level read error stays fatal.
- **Off-by-four panic in DVR index rebuild on a truncated period file.**
  `rebuild_index` guarded with `mdat_offset + 4 <= data.len()` but then read
  `&data[mdat_offset + 4..mdat_offset + 8]` — eight bytes behind a four-byte
  bound. A period file truncated between a `moof` and the `mdat` four-CC (a
  routine shape after a power loss or full disk mid-write — exactly when
  `rebuild_index` exists to recover) panicked instead of recovering.
- `routes.dvr` was validated (`Route::validate_dvr`) but never actually
  wired into the `RouteHandle` built for a config-driven or admin-API-added
  route — `RouteHandle::with_dvr` was only ever called from this crate's
  own unit tests, so DVR recording configured via `Config`/JSON silently
  never ran. Both `origin::serve_with_registry_impl` and
  `origin::admin::RouteRegistry::spawn_route` now call `.with_dvr(route.dvr.clone())`;
  found while implementing catch-up serving (issue #900), which depends on
  DVR actually recording.
- RTMP push (issue #934) shipped raw MPEG-2 TS as an RTMP `send_video`
  message payload — no RTMP server can decode that; RTMP Audio/Video
  messages carry FLV `AudioTagHeader`+`AACAUDIODATA` /
  `VideoTagHeader`+`AVCVIDEOPACKET` bodies. `PushTransport` gained
  `send_media` (default: mux with `TsMux` and send the blob, what SRT/RTSP
  push both want); `RtmpTransport` overrides it to split each batch into
  FLV-framed payloads (`transmux::flv_frame_payloads`) dispatched through
  `send_video`/`send_audio`, and its `setup` now sends `onMetaData` plus the
  AVC/AAC sequence headers once, before any frame data. Verified against a
  real, independent RTMP server implementation over a real TCP loopback
  connection (`tests/push_rtmp.rs`), not just an inspection of the client's
  own byte construction.
- RTMP push's `app`/`stream_key` were derived wrong from the push URL: `app`
  took the *whole* path and `stream_key` was always empty. An RTMP URL is
  `rtmp://host/app/streamkey` (or `.../app/instance/streamkey`) — the last
  path segment is now the stream key, everything before it the app.

## [0.8.0] - 2026-08-07

### Added
- **Push re-egress outputs** (issue #744): `OutputKind::SrtPush`,
  `OutputKind::RtmpPush`, `OutputKind::RtspPush` for relaying ingested media
  to downstream servers — turns multimux from an HTTP-only origin into a
  relay/gateway.
- `PushFormat` config enum (`Ts`, `Mp4`, `Mkv`) for per-output container
  format selection.
- `ReconnectPolicy` config for exponential-backoff reconnect on push outputs.
- SRT push transport (SRT Caller mode to a remote SRT Listener).
- RTSP push transport (client ANNOUNCE/RECORD to a remote RTSP server).
- RTMP push transport (client connect/createStream/publish to a remote RTMP
  server).
- `RouteHandle::await_first_trunk()` for push tasks to discover program
  availability.
- Supervisor lifecycle: push tasks spawn at route creation, cancel+join on
  route removal.

### Changed
- Requires `rtmp-runtime` 0.5 and `rtsp-runtime` 0.5 (new client-side
  publish APIs used by the push transports).

## [0.7.0] - 2026-08-05

### Added
- **MPTS (multi-programme transport stream) ingest** (issue #906):
  `ProgramTracker` now groups tracks by `TrackSpec::program_number`, producing
  one `ProgramId` per distinct TS programme. Every real DVB-T/S/C multiplex is
  MPTS — this was the sole gap preventing real DVB ingest. Non-TS sources
  (`program_number: None`) continue to collapse into one `ProgramId`.
- Mid-stream track additions (PMT version changes adding an elementary stream)
  now reach the running segmenter (issue #781). Previously, `track_specs` was a
  one-shot snapshot consumed at segmenter construction; a broadcaster adding an
  audio language or subtitle track mid-programme was logged and its samples
  silently dropped. Now `ProgramTracker` maps mid-stream additions to the same
  program, emits `SessionEvent::TracksChanged` with the complete track set, and
  `drive_program_segmenters` detects the `Trunk`'s `track_generation` change to
  admit the new track into the segmenter (or rebuild it, for fMP4) at the next
  segment boundary — with no media-sequence reset and no interruption to
  existing tracks. DASH output explicitly logs and continues serving existing
  tracks (adding a representation mid-stream needs a new `Period`, which is
  tracked for a follow-up).
- **Smooth Streaming output** (`OutputKind::Smooth`, config token `"smooth"`,
  issue #742): a route configured with it serves an MS-SSTR client Manifest
  (`/Manifest`) and fragment responses at the Smooth URL shape
  (`QualityLevels({bitrate})/Fragments({type}={start time})`), sharing the
  same fMP4 segment bytes every other output reads from the `Trunk`.
- **DVR durable segment archive** (issue #746): a per-route `dvr` config block
  with `enabled`, `archive_root`, `retention_periods`/`retention_bytes`,
  `period_duration_secs` (default 3 hours), and `overrun`
  (`"gap"`/`"stall"`/`"terminate"`). Finished segments are appended to a
  **period container file** (`<archive_root>/<route>/pN.<ext>`) — one file
  per period epoch, not one per segment. For fMP4, the init segment is
  written at the head of the file and media fragments follow; the resulting
  file is a valid CMAF track (init + concatenated fragments). For MPEG‑TS,
  segments are natively concatenable 188‑byte packets. A byte-range index
  sidecar (`pN.idx`) maps `(seq, pts, offset, len)` for O(1) lookup (issue
  #900) and is rebuildable by rescanning the period file. A new period is
  rolled on duration expiry or on fMP4 init change (mid-stream track
  addition — issue #781). Retention operates on whole periods, quantised
  to the configured duration. Recording is a `SegmentEgress` implementation
  draining its own pinning `SegmentCursor` — it never holds a lock the
  live-serving path needs and never perturbs live output.

## [0.6.0] - 2026-08-02

### Added
- **Classic MPEG-TS HLS output** (`OutputKind::TsHls`, config token
  `"ts_hls"`, issue #887): a route configured with it is served with whole
  `.ts` media segments (RFC 8216 §3/RFC 8216bis §3.1.1) instead of fMP4,
  self-initialising (no `#EXT-X-MAP`, no init segment) and classic (no
  low-latency parts — `transmux::ts_hls::StreamingTsHlsSegmenter` has no
  partial-segment concept). Container is a **per-route**, not per-output,
  property (`route::RouteHandle::with_container`): `"ts_hls"` is mutually
  exclusive with `"llhls"`/`"dash"`/`"ll_dash"` on the same route, rejected by
  `Config::validate()` at load time (a `media_plane::Trunk` has one segment
  ring per program — a program's samples are segmented into fMP4 *or* TS,
  never both, without a second ring; run two routes against the same source
  if both containers are needed today). See `output::ts_hls` and the
  README's new "Classic MPEG-TS HLS output" section.
- `OutputAuthSpec::SignedUrl` (issue #747): configures
  `broadcast_auth::Verifier::signed_url` as a route's output-auth scheme —
  `{ "scheme": "signed_url", "keys": [{ "kid": "...", "secret": "..." }, ...] }`.
  Multiple `keys` entries let secrets rotate without invalidating URLs signed
  under an older, still-listed key. `validate()` rejects an empty `keys`
  list, an empty `kid`, or a `secret` shorter than
  `broadcast_auth::SignedUrlKeySet::MIN_SECRET_LEN` (32 bytes) at config-load
  time, not per-request.
- **Runtime admin API** (issue #749): add/remove/list routes and reload the
  config file without restarting the origin — restarting previously dropped
  every live viewer on every route, not just the one being changed. Opt-in
  via the new `Config::admin` field (`AdminSpec { bind, auth }`), which binds
  a **separate** listener from the media port and requires auth (`auth` is
  mandatory, not `Option`) — starting the admin API unauthenticated is
  impossible by construction. Endpoints: `GET /admin/routes`,
  `GET /admin/routes/{name}`, `POST /admin/routes` (body: the same `Route`
  config shape, `409` on a duplicate name), `DELETE /admin/routes/{name}`
  (`404` if unknown; drains the removed route's supervisor without
  disturbing any other route), `POST /admin/reload` (re-reads the config
  file and converges added/removed/changed routes — an unchanged route is
  never restarted). See `crate::origin::admin` and the README's new
  "Runtime admin API" section.
- New `origin::serve_config_file`/`serve_config_file_with_registry` entry
  points: load a JSON config from a path and remember it, so
  `POST /admin/reload` has a file to re-read. `multimux-cli --config <FILE>`
  now goes through this path.
- `config::Route`/`InputSpec`/`AuthSpec`/`output::OutputKind` now derive
  `PartialEq` (used by the admin reload diff to detect an unchanged route).
- `error::MultimuxError` gained `RouteExists`/`RouteNotFound` variants (the
  admin API's `409`/`404` mappings).

### Changed
- `source::hls_pull` builds/parses HLS playlists via `broadcast-hls` directly
  instead of reaching through `transmux::hls` for it (issue #878). No public
  API or behaviour change.

## [0.5.2] - 2026-07-30

### Fixed
- Floor `media-plane` to `0.1.1`. The `^0.1` bucket also contains 0.1.0,
  which is built against `transmux` 0.20, so a consumer could resolve two
  `transmux` minors into one graph and hit trait-resolution errors pointing
  at this crate's internals (#858).

## [0.5.1] - 2026-07-30

### Fixed
- Smooth-pull ingest (`source::smooth_pull`) now skips any `StreamType`
  other than `Video`/`Audio` at manifest-parse time (previously only `Text`
  was filtered, so a future `StreamType` variant would have reached an
  exhaustive match and panicked). Surfaced by `transmux`'s `StreamType`
  gaining `#[non_exhaustive]` (issue #806).
- `output::llhls`/`origin::resource` now handle a future `LlHlsBody` variant
  defensively (same status as the existing `Resource`/`Playlist` fallback),
  consistent with `ll-hls-runtime`'s `LlHlsBody` gaining `#[non_exhaustive]`.
- **DASH and LL-DASH manifests returned 503 forever on every driver-backed
  route** (shipped in v0.5.0). `RouteHandle::set_track_specs` had no
  production call site — `report_driver_progress` now syncs track specs
  from each published program's `Trunk` into the route on every poll, using
  `track_generation()` to avoid redundant syncs (issue #831).

### Added
- `tests/label_coverage.rs` drift guard (issue #806).

## [0.5.0] - 2026-07-28

### Changed (BREAKING — pre-publish hardening)
- **Five publicly reachable enums in `crate::source` are now
  `#[non_exhaustive]`**: `DashResourceId`, `DashAction` (`source::dash_pull`),
  `HlsFetchId` (`source::hls_pull`), `SmoothResourceId`, `SmoothAction`
  (`source::smooth_pull`). Each answers "which resource am I fetching" or
  "which action next" for a pull protocol, so each is exactly the kind of enum
  that gains a variant as a protocol's surface is covered more fully — and
  adding a variant to a published exhaustive enum is a breaking change.
  Bundled into 0.5.0, which is already breaking, so it costs downstream
  matchers nothing beyond the wildcard arm they need for this release anyway;
  deferring it would have required a further breaking bump later purely to
  attach an attribute.
  - `media-plane` was audited the same way and needed nothing: every public
    enum there already carries it.

### Added (issue #805 task 6: per-program serving state, MPTS-ready)
- **`RouteHandle` gains per-program serving state.** A new crate-private
  `ProgramServing` bundle groups one program's `Trunk`, its
  `LlHlsOrigin`, and its `DashState` together, keyed by `ProgramId` in
  `RouteHandle`'s registry — replacing the single owned `Trunk`/`ll_hls`/
  `dash` triple `RouteHandle::new` used to build eagerly. A `ProgramServing`
  bundle is created the instant (and only the instant)
  `RouteHandle::publish_program` is called for that program, mirroring
  `media_plane::IngestDriver` minting a `Trunk` the instant it observes
  `SessionEvent::NewProgram`. Proven end to end by a new test
  (`route::program_registry_tests::two_programs_serve_distinct_media`): two
  programs on one route now serve genuinely distinct init bytes and segments.
- **`RouteHandle::publish_new_program(program) -> Arc<Trunk>`** (new, `pub`):
  mints a `Trunk` sized like the route's own configured ring capacities and
  publishes it under `program` in one step — the test/plugin-facing
  replacement for the deleted `publish_owned_trunk`, and the only way to get
  a `Trunk` handle back from a `RouteHandle` (it is always already
  registered).
- **`RouteHandle::name()`/`with_name()`** (new, `pub`): a route's own name,
  defaulting to `"unknown"`. `crate::origin::serve_with_registry`'s one
  production route-construction call site now chains `.with_name(route.name)`
  so `crate::source::segment::drive_program_segmenters` (issue #809, below)
  can label its metrics without threading a name parameter through every
  `run_*` call site.
- **`crate::source::{DriverProgress, advance_route}`** (new, `pub`): the one
  facade a driver-backed drive loop (in-tree `run_*`, or an external
  `SchemeRegistry` `Custom` factory) now calls once per iteration, replacing
  the caller-assembled pair `report_driver_progress` +
  `segment::drive_program_segmenters` those two functions used to require
  (both narrowed back to `pub(crate)` — see Changed, below). `DriverProgress`
  is the one opaque per-attempt state value a caller declares and threads
  through; `advance_route` performs both steps, in order, every time — a
  wrong order or a skipped step (the exact footgun the old two-call API
  invited) is no longer possible. `examples/custom_scheme.rs` and
  `tests/dispatch_ingest.rs`'s `Custom`-dispatch coverage are rewritten onto
  this facade.

### Fixed (issue #804)
- **`rtsps://` (RTSP over TLS) ingest works again.** The step-5a port left
  `source::rtsp::run_rtsp` wiring only a plain `TcpStream`, and refusing an
  `rtsps://` URL outright with an error quoting an internal task number — a
  user-visible capability regression against 0.4, on a scheme that is common
  on IP cameras. `run_rtsp` now branches on the URL scheme
  (`RtspDialer::is_tls`, already present and tested) and, for `rtsps://`,
  TCP-connects then performs a real `tokio_rustls` handshake before any RTSP
  is exchanged — trusting the public-CA `webpki-roots` bundle via
  `rtsp_runtime::io::default_tls_client_config`, the same config
  `rtsp_runtime::io::AsyncRtspClient::connect_tls_with` uses. Both transports
  are erased to a boxed `AsyncRead`/`AsyncWrite` pair, so the sans-IO
  `poll_transmit`/`feed` drive loop is written once and never has to know
  which one it got. Gated behind this crate's existing default-on `tls`
  feature; with `--no-default-features`, an `rtsps://` route fails fast with
  an error naming the missing feature.
  - The interim refusal did at least **fail safe** — it never opened a
    plaintext socket to a TLS port — and that property is preserved and now
    *asserted* rather than assumed: a new test points a real `rtsps://` route
    at a deliberately-plain loopback listener and checks the bytes the
    listener actually received begin with a TLS handshake record (`0x16`,
    major version `0x03`), never the ASCII first byte of an RTSP request
    line.
  - **SNI derivation restored.** `sni_server_name` (dropped in the 5a port,
    and *not* relocated — `AsyncRtspClient::connect_tls_with` takes a
    caller-supplied `server_name` and does no bracket-stripping of its own)
    strips brackets from IPv6 literals, since rustls'
    `ServerName::try_from` rejects them: `[2001:db8::1]` -> `2001:db8::1`,
    with hostnames and IPv4 literals passed through unchanged. Without it,
    an `rtsps://` camera addressed by IPv6 literal would fail — and *only*
    that case, which ordinary testing misses. Also exposed as
    `RtspDialer::sni_server_name`.
  - A real TLS handshake is exercised in-tree: a new loopback test runs a
    genuine `tokio_rustls` server (self-signed `CN=localhost` fixture cert,
    shared byte-for-byte with `rtsp-runtime`'s own TLS loopback test) and
    drives a full DESCRIBE -> SETUP -> PLAY -> interleaved-RTP-depayload
    exchange over the encrypted socket, asserting a real depayloaded sample
    lands in the `Trunk`.

### Fixed (issue #809)
- **`multimux_parts_produced_total`/`multimux_segments_produced_total` have
  an emitter again.** These two counters had no emitter at all since the
  media-plane port — they read zero while media flowed perfectly (worse than
  absent) before being deleted outright pending this fix (see the entry
  below). `crate::source::segment::drive_program_segmenters` — the one place
  in the driver-backed architecture that actually turns samples into
  parts/segments — now bumps both, labelled by the new `RouteHandle::name()`
  (see Added, above) rather than threading a route-name parameter through
  nine `run_*` call sites. A new test
  (`source::segment::tests::drive_program_segmenters_bumps_parts_and_segments_produced_counters`)
  asserts both counters actually increment for a driver-backed route.
  Swept the rest of `crate::prometheus` for the same failure mode: `ROUTE_UP`/
  `SOURCE_RECONNECTS_TOTAL` (`origin::supervisor`), `ACTIVE_BLOCKING_REQUESTS`
  (`origin::resource`/`output::llhls`), and `HTTP_REQUESTS_TOTAL`/
  `HTTP_REQUEST_DURATION_SECONDS`/`BYTES_SERVED_TOTAL` (`origin`'s HTTP
  middleware) all still have live emitters — no other casualty found.

### Removed (BREAKING — issue #805 task 6: the placeholder `Trunk` is gone)
- **`RouteHandle`'s owned placeholder `Trunk` field and `publish_owned_trunk`
  are deleted.** `RouteHandle::new` no longer builds any `Trunk`/`LlHlsOrigin`/
  `DashState` at all; every program's serving state lives only in the new
  per-program registry (see Added, above), created only by
  `publish_program`/`publish_new_program`. This makes the **publish-or-hang
  footgun structurally impossible** rather than merely documented against: a
  producer can no longer write into a `Trunk` that egress cannot resolve,
  because there is no `Trunk` to write into until it is published. Callers
  that used to build a route, write via `set_init`/`add_part`/`add_segment`,
  then call `publish_owned_trunk()` now call `publish_new_program(program)`
  **first** (it both mints and publishes), then write.
- **`RouteHandle::set_init`/`init_bytes`/`set_track_specs`/`track_specs`/
  `add_part`/`add_segment`/`window_segments` all take a new leading
  `ProgramId` parameter.** These used to operate on the single owned `Trunk`;
  they now resolve (or, for the writers, silently no-op with a logged
  warning if unpublished) the named program's `ProgramServing` bundle. Every
  in-tree egress call site resolves `crate::route::SPTS_PROGRAM_ID` (the
  SPTS default, unchanged behaviour for every existing route) via the new
  `crate::http::resolve_route_program` (renamed from `resolve_route_trunk`,
  which returned just the `Trunk`; the renamed function returns the whole
  `ProgramServing` bundle so a caller never risks pairing one program's
  `Trunk` with a different program's `LlHlsOrigin`).
- **MPTS addressing is documented, not implemented** (as scoped): with
  per-program serving state in place, a route can genuinely serve several
  programs, but there is still no way for an HTTP request to *select* a
  non-default one. `RouteHandle`'s own module doc records three options (URL
  path segment, query parameter, config-declared per-program route) and
  recommends the query parameter as the additive "MVP" choice, plus a known
  gap: `ProgramResolution::NotYetAnnounced` vs. `NotFound` is derived from
  "the registry is empty", which cannot yet distinguish "this MPTS program
  hasn't been minted yet" from "this program will never exist" for a
  route where at least one *other* program has already landed.

### Fixed (issue #808)
- **Samples published in the SAME `feed` call as `NewProgram` are no longer
  silently dropped.** `ProgramSegmenter::try_new` now subscribes with
  `media_plane::trunk::Trunk::subscribe_from_backlog` instead of `subscribe`:
  the driver's own feed batch that announces a program routinely carries its
  first samples too (a single MPEG-TS feed of 64 packets commonly carries
  the PMT and the first PES packets together), and those samples were
  already sitting in the ring by the time `drive_program_segmenters` built
  the segmenter — a live-tail `subscribe()` cursor never observed them. If
  the dropped batch held the opening IDR, the first segment either started
  on a non-keyframe or was delayed; this was silent (no error, no log).
  `examples/custom_scheme.rs` and `tests/dispatch_ingest.rs` no longer split
  their announce/sample script across two `feed` calls to work around this —
  both now announce and publish in one call, the ordinary shape.

### Removed (BREAKING — issue #805 task 5/6: convergence)
- **`SourceConnector`, `supervise`, and the whole `pipeline` module are
  deleted.** Every input kind now dials/listens over
  `media_plane::ingress`'s `Dialer`/`Listener` + `IngestSession` traits, driven
  by `supervisor::supervise_driver` — RTMP (task 4) was the last holdout, and
  once it moved, `SourceConnector`/`supervise` (and the `pipeline::SampleSource`/
  `run_pipeline`/`MockSource` trio it drove) had no remaining caller.
  - `pub use origin::supervisor::{Backoff, SourceConnector, supervise};` is now
    `pub use origin::supervisor::{Backoff, supervise_driver};` — a downstream
    crate implementing `SourceConnector` or calling `supervise`/
    `multimux::pipeline::*` directly no longer compiles against this crate;
    port onto `supervise_driver` over your own `Dialer`/`IngestSession` (see
    `examples/custom_scheme.rs`, rewritten to demonstrate exactly this).
  - The `testsupport` feature and the `serve_mock` example are removed with
    it: both existed solely to gate `pipeline::MockSource`.
- **`RouteHandle`'s owned `Trunk` field now has no production writer.** It
  stays (removing it forces `ll_hls`/`dash` to be built per-program instead of
  once in `RouteHandle::new`) but is now a pre-first-program **placeholder**,
  driven only by this crate's own `tests/*.rs` (via `set_init`/`add_part`/
  `add_segment` + `publish_owned_trunk`, which stays `pub` for exactly that
  reason). See `RouteHandle`'s own doc. **Superseded by issue #805 task 6,
  below: the placeholder and `publish_owned_trunk` are deleted outright.**
- **`crate::prometheus::{SEGMENTS_PRODUCED_TOTAL, PARTS_PRODUCED_TOTAL}`
  removed** (the two counters became dead code — the deleted `run_pipeline`
  was their only caller; no driver-backed `run_*` path ever bumped them, since
  `crate::source::segment::ProgramSegmenter` has no route name to label them
  with). Restoring `multimux_segments_produced_total`/
  `multimux_parts_produced_total` for the driver-backed architecture is a
  separate, unscoped follow-up (threading a route name through
  `drive_program_segmenters`/`ProgramSegmenter`, touching every `run_*` call
  site) — flagged here rather than silently dropped or hastily wired up
  underneath this task.

### Changed (issue #805 task 5/6: the plugin extension point)
- **`crate::source::report_driver_progress` and
  `crate::source::segment::{ProgramSegmenter, drive_program_segmenters}` are
  now `pub`** (were `pub(crate)`). These are the two per-iteration calls every
  in-tree driver-backed `run_*` makes; with `SourceConnector`/`supervise`
  gone, they are also the *only* way an external `SchemeRegistry`-registered
  `Custom` input factory driving its own `Dialer`/`IngestSession` can publish
  its ingest into `RouteHandle`'s (crate-private) program registry and turn
  its samples into LL-HLS-servable segments/parts — without this, the
  extension point documented in `crate::registry`/`examples/custom_scheme.rs`
  would be unusable for anything beyond a trivial connect-only stub.
  **Superseded by issue #805 task 6, above: narrowed back to `pub(crate)`
  behind the single `crate::source::advance_route` facade.**
- **`examples/custom_scheme.rs` rewritten** to demonstrate the supported
  plugin shape: a small `Dialer`/`IngestSession` pair (`DemoDialer`/
  `DemoSession`, synthetic single-track AVC media) driven by
  `supervise_driver`, publishing through `report_driver_progress` +
  `drive_program_segmenters` exactly like a built-in source. The example now
  actually invokes the registered factory and waits for real init bytes to
  land, rather than only checking registry lookup + config parsing. The
  `"silence"`-tagged scheme is renamed `"demo"` (`examples/custom-scheme.json`
  updated to match) since it now carries real synthetic media, not silence.
- **`examples/serve_mock.rs` deleted** rather than rewritten: its
  demonstration value (drive a synthetic ingest end to end, serve it over a
  real HTTP origin, no camera/ffmpeg needed) is now covered by
  `examples/custom_scheme.rs` (same Dialer/IngestSession/supervise_driver/
  segmenting mechanics) together with `tests/dispatch_ingest.rs`'s real
  end-to-end HTTP tests (`ts_udp`/`ts_http`/`rtmp` dispatch tests already
  serve real — not synthetic — fixture-derived media over real HTTP).
- **`tests/dispatch_ingest.rs`'s `InputSpec::Custom` coverage retargeted onto
  the new plugin shape**: `custom_dispatch_drives_a_driver_backed_source_and_serves_real_media`
  replaces `custom_dispatch_drives_run_pipeline_and_serves_real_media`,
  registering a `Custom` factory that spawns `supervise_driver` over a small
  `IngestSession` fed the real (demuxed, not synthetic) `h264_aac.ts` fixture
  — the dispatch path (`InputSpec::Custom` -> `SchemeRegistry` -> `InputCtx`
  -> factory -> real HTTP `#EXTINF:`) is still fully covered, just through
  the surviving architecture.
- **`tests/origin_llhls.rs`'s first test retargeted** onto a real
  `LlHlsSegmenter` fed directly (mirroring `tests/lldash_dashjs.rs`'s own
  `run_live_producer`) in place of the deleted `run_pipeline`/`MockSource`;
  the file's `#![cfg(feature = "testsupport")]` gate is removed (nothing in
  it needs `MockSource` any more).
- **`multimux::origin::supervisor::supervise_driver` gains direct unit
  test coverage** (`origin::supervisor::tests`): a fake `attempt` closure
  (replacing the deleted `SourceConnector`-based `FlakyConnector`/
  `PacedFlakyConnector` mocks) proves reconnect-after-failure,
  reconnect-after-live-attempt-ends, and shutdown-cancels-mid-backoff —
  properties `supervise`'s own tests used to prove for the now-deleted loop.

### Added (in progress — issue #805, task 1 of 6)
- **`RouteHandle` gained a `ProgramId -> Arc<Trunk>` registry**, the first step
  of converging multimux's two ingest architectures onto one. `publish_program`
  is the ingest-side write; `resolve_program` returns a typed three-case
  `ProgramResolution``Found(Arc<Trunk>)`, `NotYetAnnounced`, `NotFound`.
  Held in an `RwLock<HashMap<..>>` because resolution is the hottest read path
  once egress is wired to it (every served request, every viewer) while
  publication is rare and bounded by `IngestDriver`'s `max_programs`.
  - The three cases are deliberately **not** an `Option`: "this route is
    connected but no program has appeared yet" is a wait, whereas "no such
    program" is a 404, and collapsing them would make a still-connecting
    route indistinguishable from a typo in a request path.
  - The registry carries no single-program assumption, so MPTS support (one
    route, N programs, one handle) is an addition rather than a reshaping.
  - Additive only: `RouteHandle` still owns its legacy `trunk` field and
    egress still reads it, so nothing changes behaviourally yet.

### Fixed
- **`pipeline::run_pipeline` published nothing, so every consumer of it hung.**
  Once egress resolved exclusively through the route's program registry
  (task 2), a producer that writes `RouteHandle`'s own `Trunk` without
  indexing it there is served to nobody — and because a request then blocks on
  `ProgramResolution::NotYetAnnounced` waiting for a program that is already
  present, the symptom is an **infinite hang, not a 404**. `run_pipeline` is a
  public entry point and never published. It now calls
  `RouteHandle::publish_owned_trunk()`, matching `origin::supervisor::supervise`.
  - Caught by `ll-hls-runtime`, which dev-depends on this crate and drives
    `RouteHandle` + `LlHlsOutput` directly: its `glass_to_glass` test tripped a
    25 s hang guard and `golden_gate` a 20 s one, both reproducibly, both green
    before task 1/2 landed. An external consumer found this, not our own suite.
  - `RouteHandle::new`'s docs now state the contract and that the failure mode
    is a hang. `new()` deliberately does **not** auto-publish: a bare,
    unpublished route is the genuine driver-route connecting window that the
    four `NotYetAnnounced` → 503 tests assert, and the owned field carrying
    this hazard goes away once every route is driver-backed.
- **The workspace doc gate is green again (26 `error:` lines → 0).** The 5a/5b
  port renamed and deleted types without updating the prose that referenced
  them, leaving dead intra-doc links across twelve files: `RtspSource` (split
  into `RtspDialer` + `RtspIngestSession`), `RtpUdpSource`/`TsUdpSource`/
  `TsHttpSource`/`SrtSource` (renamed to their `*Route` config types), and
  `crate::store::MediaStore`/`HealthState` (that module was deleted outright;
  `HealthState` now lives at `crate::route::HealthState`). Public docs also
  linked to private items (`crate::http::resolve_blocking`,
  `into_response`, `select_representable_track`), which are now plain
  backticked code rather than links — no API was widened to satisfy rustdoc.
  - One correction went beyond relinking: `serve_with_registry`'s docs claimed
    every `InputSpec` variant dispatches through `supervisor::supervise`. Only
    `Rtmp` and `Custom` do. The prose now says so (see issue #805).
### Changed (issue #805 task 2/6 — wire the eight `media_plane`-ported inputs)
- **All nine ingest input kinds now genuinely ingest and serve, via
  `RouteHandle`'s program registry** (task 1 added the registry additively;
  this wires both sides of it). `rtsp`/`rtp`/`ts_udp`/`ts_http`/`srt`/
  `hls_pull`/`dash_pull`/`smooth_pull` were ported onto
  `media_plane::ingress::{Dialer, IngestSession, IngestDriver}` at plan step
  5a but left unreachable from `origin::serve_with_registry` — a combined
  match arm logged an error and spawned a no-op. That stub is deleted.
  - New `origin::supervisor::supervise_driver`: the driver-backed sibling of
    `supervise`, reusing its exact operational shape (`Backoff` between
    attempts, `RouteHandle::health` transitions, `record_route_up`/
    `record_reconnect`, a cancellable shutdown `watch::Receiver<bool>`) for
    the eight input kinds whose `run_*` entry point fuses dial+drive into one
    call rather than exposing a separate `SourceConnector::connect()` step.
  - New `crate::source::report_driver_progress`, called by every
    `run_rtsp`/`run_rtp_udp`/`run_ts_udp`/`run_ts_http`/`srt::drive_socket`/
    `run_hls_pull`/`run_dash_pull`/`run_smooth_pull` from inside its own drive
    loop: flips the route to `Live` the moment its `IngestDriver` establishes,
    and publishes each newly-announced program's driver-minted `Trunk` into
    the route's registry (`RouteHandle::publish_program`). All eight `run_*`/
    `drive_socket` entry points gained a `route_handle: &Arc<RouteHandle>`
    parameter for this (source-breaking for direct callers of those `pub`
    functions).
  - `origin::serve_with_registry`'s per-route ingest wiring moved into a new
    `spawn_ingest` helper, one arm per `InputSpec` variant, so it is
    individually testable without a real HTTP server.
- **Egress now resolves every route through the registry, uniformly**  migrated the five `RouteHandle::trunk()` call sites
  (`output::llhls::media_playlist`, `output::dash::manifest`,
  `output::ll_dash::manifest`, `origin::resource::dynamic_file`/`fetch_part`)
  onto a new shared `crate::http::resolve_route_trunk`, which resolves
  `RouteHandle::SPTS_PROGRAM_ID` (the single-program-route default; MPTS
  resolution is task 6) and maps the three-way
  `RouteHandle::ProgramResolution`: `Found` resolves and serves;
  `NotYetAnnounced` (connected, but no program has appeared yet) is a `503`
  "not ready", **not** a `404`; `NotFound` is a genuine `404`. The now-unused
  `RouteHandle::trunk()` accessor was deleted (the owned `Trunk` **field**
  stays — task 5 removes it).
  - So RTMP/`Custom` (the old `SourceConnector`-fed path) keep serving through
    this same migration with no fallback branch in egress:
    `origin::supervisor::supervise` now calls a new, `pub`
    `RouteHandle::publish_owned_trunk()` right after reaching `Live`,
    publishing its own owned `Trunk` into the registry under
    `SPTS_PROGRAM_ID`. `pub` (not `pub(crate)`) because this crate's own
    `tests/*.rs`/examples that drive `crate::pipeline::run_pipeline` directly
    (bypassing `supervise`) call it explicitly to stay resolvable.

### Added (issue #805 task 2b/6 — close the segmenter gap)
- **The eight driver-backed inputs now actually produce segments/parts, not
  just samples.** Task 2 wired ingest (each publishes its driver-minted
  `Trunk` into `RouteHandle`'s registry) and made egress resolve through that
  registry, but nothing turned the raw samples the driver publishes into
  segments/parts — a driver-backed route's LL-HLS/DASH playlists came back
  empty. New `crate::source::segment::ProgramSegmenter` +
  `drive_program_segmenters` close it: one `ProgramSegmenter` per announced
  `ProgramId`, subscribing a `SampleCursor` to that program's driver-minted
  `Trunk`, feeding a `transmux::ll_hls::LlHlsSegmenter`, and publishing the
  resulting parts/segments back into **the same `Trunk`** via its own
  `Trunk::segment_writer()` — never a second `Trunk`, never copying samples
  between trunks (the decision recorded in
  `docs/superpowers/specs/2026-07-26-media-plane-architecture.md` §8).
  Segmenting is per-program, so an MPTS route (several programs on one
  `IngestDriver`) segments each independently; only `SPTS_PROGRAM_ID`'s init
  bytes are wired to `RouteHandle`'s single-slot LL-HLS/DASH accessors today
  (MPTS egress resolution is task 6). All eight `run_*`/`drive_socket` entry
  points call `drive_program_segmenters` right alongside
  `report_driver_progress`, and again after `driver.finish()` where the loop
  shape made that easy (`ts_http`/`srt`/`hls_pull`/`dash_pull`/`smooth_pull`/
  `rtsp`), so a cleanly-ending session flushes its trailing partial segment
  instead of dropping it.
  - Killed the two remaining `MOVIE_TIMESCALE = 90_000` magic-number
    hardcodes (`source::ts_program`'s and `source::hls_pull`'s test fixtures)
    and `pipeline::run_pipeline`'s own copy, in favour of
    `transmux::VIDEO_CLOCK_RATE` — matching `source::smooth_pull`'s existing
    convention.
- **`RouteHandle` rebinds its `ll_hls`/`dash` to a driver-minted `Trunk`.**
  `LlHlsOrigin`/`DashState` are bound to one `Trunk` at construction, and
  `RouteHandle::new` builds them from its own placeholder `Trunk` before any
  program is known. `ll_hls`/`dash` are now `RwLock<Arc<..>>`, and
  `publish_program` — for `SPTS_PROGRAM_ID` only — rebuilds them over
  whichever `Trunk` was just published, but **only** if it is a genuinely
  different `Arc` from the one they are currently bound to (`Arc::ptr_eq`
  guard): a same-`Arc` republish (the legacy RTMP/`Custom` path always
  publishes this handle's own `trunk`, already what `ll_hls`/`dash` were
  built from) is a strict no-op, so it never discards init bytes/segments/
  parts a caller already wrote before publishing. New `active_trunk` field
  tracks which `Trunk` is authoritative for `latest_progress()`'s abuse-bound
  check. Without this, `ProgramSegmenter` publishing into the driver's own
  `Trunk` (as decided above) would never be visible to `route.ll_hls()`,
  which stayed bound to the route's own never-written placeholder forever.

### Added (issue #805 task 3/6 — the dispatch-path regression net)
- **Closed the hole that let eight of nine `InputSpec` variants dispatch to a
  no-op stub for a long time while build/clippy/doc and thousands of tests
  all stayed green** (see `docs/superpowers/specs/2026-07-26-media-plane-architecture.md`
  §8's own account): no test in the suite entered through
  `serve_with_registry`/`spawn_ingest` itself — every source was well-tested
  in isolation (`RtspDialer`/`RtspIngestSession` directly, a hand-built
  `RouteHandle`), but the dispatch that routes to them was tested by nothing.
  - **Layer 1 (exhaustive, cheap):** new
    `origin::tests::every_input_spec_variant_dispatches_to_real_ingest_not_a_stub`
    points every non-`Custom` `InputSpec` variant at a deliberately dead/
    unreachable endpoint (a refused TCP connect, a quiet UDP port, an SRT
    caller dialing nobody, or — for `Rtmp` — a listen port the test has
    already stolen) and asserts `RouteHandle::health` transitions
    `Connecting` -> `Reconnecting`; a stubbed arm would never touch
    `route_handle` at all, so it would still read `Connecting` at the hang
    guard. Exhaustive over the enum via a **compile-time** net: a second,
    independent exhaustive `match` over `InputSpec` (no `_ =>` arm) fails to
    compile the moment a new variant is added without also updating this
    test — stronger than any runtime assertion, and only possible because
    the test lives in-crate (`InputSpec` is `#[non_exhaustive]`, so an
    external `multimux/tests/*.rs` match would be forced to carry a
    wildcard arm that silently swallows a new variant).
  - **Layer 2 (deep, representative):** new `multimux/tests/dispatch_ingest.rs`
    drives real fixture bytes (`fixtures/ts/h264_aac.ts`, a real ffmpeg
    capture — never synthesised bytes) through `serve_with_registry` for
    `InputSpec::TsUdp` (a real UDP socket) and `InputSpec::TsHttp` (a small
    loopback HTTP server), and asserts a real HTTP `GET` of the resulting
    LL-HLS media playlist carries an actual `#EXTINF:` line (not merely the
    `#EXT-X-PART-INF`/`#EXT-X-MAP` headers a zero-segment route still emits)
    plus non-empty init/segment bytes.
  - **`pipeline::run_pipeline` coverage:** the same file's
    `custom_dispatch_drives_run_pipeline_and_serves_real_media` drives
    `run_pipeline` through the exact `InputSpec::Custom` ->
    `SchemeRegistry` -> `InputCtx` -> factory path a real embedding
    application uses, catching a regression of `run_pipeline`'s
    `publish_owned_trunk()` call (see the "Fixed" entry above) the same way
    a real deployment would notice it — every request hanging, not erroring.
  - Every new test is mutation-verified: the exact production stub/removed
    call was restored, the specific assertion/panic confirmed, then
    reverted — see each test's own `MUTATION VERIFIED` doc comment.

### Added (issue #805 task 4/6 — RTMP onto `media_plane::ingress::Listener`)
- **RTMP is off the old `SourceConnector`/`supervise` path** — the last of
  the nine input kinds to move onto `media_plane::ingress`, closing the
  "eight ported, one held back" gap tasks 2/2b/3 deliberately left open (see
  `docs/superpowers/specs/2026-07-26-media-plane-architecture.md` §8's
  sequencing note: RTMP was kept on the working old path until every other
  kind, and the registry reconciliation it depends on, was proven).
  - New `media_plane::ingress::ListenDriver::driver`/`driver_mut`/
    `reap_if_terminal` (in `media-plane`, additive): RTMP's `Listener::Session`
    is fed already-parsed-and-replied-to `rtmp_runtime::server::ServerEvent`s
    (`Stage::In<'a> = &'a [ServerEvent]`, not `&'a [u8]` — see
    `source::rtmp`'s module doc for why `AsyncRtmpServer`/`RtmpConnection`
    make that the honest shape), so `ListenDriver::feed`'s `&[u8]`-pinned
    convenience wrapper doesn't fit. These three accessors let a driving loop
    reassemble the same feed -> observe -> reap sequence for any `Stage::In`
    shape.
  - `source::rtmp::RtmpRoute` (replaces `RtmpSource`) implements
    `media_plane::ingress::Listener`: `AsyncRtmpServer::accept()` (blocking-
    async, no `try_accept`) is bridged into `Listener::poll_accept`'s
    non-blocking contract by an accept-pump task, spawned once alongside the
    bind-once listen socket, that loops `accept().await` and sends each
    `RtmpConnection` into a bounded `mpsc` channel; `poll_accept` drains it
    with `try_recv()`.
  - New `source::rtmp::run_rtmp`: a `ListenDriver`-backed loop that admits and
    drives up to `max_sessions` publishers **concurrently** (`FuturesUnordered`
    over one read task per admitted session), fixing a real, previously-only-
    mitigated defect — `supervise` awaited `RtmpSource::connect()`
    *sequentially* against the bind-once listener, so a publisher that
    completed the handshake and then went idle wedged the entire route, never
    accepting another publisher (#738 T11b review, Critical). One stalled
    publisher no longer blocks another (see `second_publisher_is_served_while_first_is_stalled_at_handshake`).
  - Preserved exactly: bind-once/reuse-forever (via `tokio::sync::OnceCell`,
    shared across every `run_rtmp` attempt), `Established` gating on the
    first `DemuxEvent::Sample` rather than the first `TrackAdded` (FLV has no
    `TracksResolved`; leans on `transmux::flv_stream`'s Annex E ordering
    assumption, exactly as `SessionEvent::Established`'s own doc prescribes
    for RTMP), the first sample never being dropped (buffered, then
    re-emitted immediately after the `NewProgram`/`Established` pair), and
    `IngestTimeouts` still bounding every read.
  - RTMP publishes under `SPTS_PROGRAM_ID` (`ProgramId(0)`) like every other
    single-program driver-backed source; MPTS is task 6, out of scope here.
  - Why RTMP *can* implement `Listener` where SRT's listener mode explicitly
    cannot (see `source::srt`'s "Why listener mode is not a `Listener` yet"):
    `AsyncRtmpServer::accept` takes `&self` (shareable via `Arc`, no `Mutex`
    needed) and each accepted connection owns its own `TcpStream` — SRT's
    blockers (a `&mut self` accept, one shared `UdpSocket` across the
    listener and every connection) are specific to `srt-runtime`'s current
    API, not a general objection to a push source using `Listener`.
  - `crate::pipeline`'s `SampleSource for RtmpSession` impl and
    `crate::origin::supervisor`'s `SourceConnector for RtmpSource` impl are
    deleted (RTMP no longer implements either). `SourceConnector`/`supervise`/
    `run_pipeline` themselves are unchanged — `InputSpec::Custom` still uses
    them (task 5 removes them, once `Custom` is the only remaining user).

### Changed (BREAKING, in progress — plan step 5b)
- **Ported the three outputs (LL-HLS, DASH, LL-DASH) and the shared
  init/segment/part resource route onto `media_plane::egress::ServedEgress`
  behind one axum adapter**, and **deleted `crate::store` (`MediaStore`,
  `HealthState`)** — the module was a re-export of `ll_hls_runtime::server`
  types Step 4 replaced with the sans-IO `LlHlsOrigin`/`Trunk` design, and
  multimux has not compiled since. This unblocks the whole workspace: it has
  not built since Step 4 deleted `MediaStore`.
  - New `crate::route::RouteHandle` replaces `MediaStore` as the shared
    per-stream state: a `media_plane::Trunk`, the `LlHlsOrigin` over it
    (serves every output's init/segment/part bytes and LL-HLS's own
    playlist), a small `Trunk`-drained `DashState` (track specs + created-at
    + closed-segment window — the only things no `Trunk` ring holds), and a
    new `crate::route::HealthState` (route up/down; distinct from
    `media_plane::ingress::HealthState`, which is generic over one ingest
    session's own connector error type and cannot give one route's
    homogeneous live/down status). Same public method surface as the deleted
    `MediaStore` (`set_init`/`init_bytes`/`set_track_specs`/`track_specs`/
    `add_part`/`add_segment`/`window_segments`/`health`/`set_health`) so
    `crate::pipeline::run_pipeline`/`crate::origin::supervisor::supervise`
    needed only a type-rename, not a rewrite.
  - New `crate::http` module: the **one** axum adapter every route goes
    through — `resolve_blocking` (the caller-driven blocking-reload wait
    loop `ll_hls_runtime::server`'s own module doc sketches, generalised to
    any `ServedEgress`) and `into_response` (`EgressResponse` -> HTTP
    response, `Await`/`NotFound`/`BadRequest` mapped once, not per output).
    `crate::output::dash`/`crate::output::ll_dash` now implement
    `ServedEgress` too (`Request = ()`, `Body = String`) purely so their
    manifest routes go through the same adapter as LL-HLS, even though
    neither ever answers `Await`.
  - `crate::origin::resource`'s shared `/:file` route now resolves through
    `LlHlsOrigin::resolve` (`LlHlsRequest::Resource`) instead of the deleted
    `MediaStore::resolve_resource`; the issue #721 chunked-transfer
    in-progress-segment path is unchanged in shape, re-fetching parts via
    the same adapter.
  - **Fix #776**: `render_mpd`/`render_ll_dash_mpd` took `specs.remove(0)`
    unconditionally, so a track set whose first elementary stream is an
    opaque `CodecConfig::Data`/`Subtitle` track (teletext, DSM-CC, SCTE-35 —
    routine in a real DVB multiplex) made `DashPackager`/`LlDashPackager`
    reject the `Media` and the whole DASH/LL-DASH route return a
    **permanent 503**. New `crate::output::dash::select_representable_track`
    selects the first track (preferring a video-shaped codec, then any
    other) that actually trial-packages successfully through the real
    `DashPackager` — reusing its codec-support decision rather than
    re-deriving it — so a representable track behind an opaque one is no
    longer starved. Only a track set with genuinely no representable track
    is still a 503.
  - The two RFC 8216bis behaviours that shipped as multimux 0.2.1 (a
    preload-hinted part blocks until produced rather than 404ing) and 0.2.2
    (a just-closed segment's final part still serves) now fall out of the
    `Trunk`'s live-part log for free (segment close deliberately never
    evicts parts — see `media-plane`'s own module doc) — both are asserted
    directly in `crate::origin::resource`'s own tests, not merely assumed to
    still hold.
  - Fixed two pre-existing, unrelated defects the first successful
    `cargo test --workspace`/`cargo clippy --workspace --all-targets` since
    Step 4 surfaced (not caused by this port): `ll-hls-runtime`'s own
    `LlHlsOrigin` test helpers and two of its examples
    (`client_stepping`/`origin_playlist`) called `Trunk::writer()` (the
    samples+events writer) instead of `Trunk::segment_writer()` for
    segment/part publishing — a stale call from before Step 4's
    `SegmentWriter` split that never compiled since; and multimux's
    `AuthSpec::to_credentials` had gone dead (its only call site was the
    per-route ingest wiring Step 5a rounds 2/3 replaced with a
    `tracing::error!` stub for every non-`rtmp` input), now `#[allow(dead_code)]`
    with its reason recorded rather than deleted, since the (tested) logic
    is still exactly what that wiring will need once it lands.
  - **Not fixed, reported**: `multimux/tests/rtsp_ingest.rs` has not
    compiled since Step 5a round 2 ported `crate::source::rtsp` off
    `RtspSource`/`SourceConnector` onto `run_rtsp`/`IngestDriver` — the test
    file was never updated and still imports the deleted `RtspSource` (and
    references a `Sample::flags` field the media-plane step 2c PTS/DTS
    refactor also removed independently). Out of this step's scope (RTSP
    ingest wiring is a later step's job, not the output port); every other
    target in the workspace builds and passes.

### Changed (BREAKING, in progress — plan step 5a, round 3)
- **Ported `hls_pull`, `dash_pull` and `smooth_pull` onto the plane's ingress
  traits**, on the back of `media-plane`'s round-3 `IngestSession` change
  (relaxed `Stage::In` + associated `Request` — see that crate's CHANGELOG
  for the trait diff and the reasoning). Round 2 deliberately did **not**
  port these three because contorting them into the then-current trait would
  have produced a session whose `Stage::feed` is never called; the trait now
  expresses what they actually do, so the port is honest rather than a
  workaround.
  - `HlsPullSource`/`HlsPullSession``HlsPullRoute`/`HlsPullDialer`/
    `HlsIngestSession` + `run_hls_pull`. **Now wraps the sans-IO
    `ll_hls_runtime::client::LlHlsClient` directly, not `TokioClient`.**
    `TokioClient` owns its own `reqwest` fetch loop internally, so there was
    nothing for `feed`/`poll_transmit` to drive; `LlHlsClient` is the sans-IO
    core `TokioClient` itself wraps. This module is now the *other* adapter
    over the same engine — all LL-HLS logic (reload scheduling, part/segment
    dedup, fMP4 and issue-#760 classic-TS demux) still lives entirely in
    `ll-hls-runtime`. `Request = ll_hls_runtime::client::Action`;
    `In<'a> = (HlsFetchId, &'a [u8])`, where `HlsFetchId::{Playlist,
    Resource(ResourceId)}` is this module's own correlation identity.
  - `DashPullSource`/`DashPullSession``DashPullRoute`/`DashPullDialer`/
    `DashIngestSession` + `run_dash_pull`. `Request = DashAction`;
    `In<'a> = (DashResourceId, &'a [u8])`.
  - `SmoothPullSource`/`SmoothPullSession``SmoothPullRoute`/
    `SmoothPullDialer`/`SmoothIngestSession` + `run_smooth_pull`.
    `Request = SmoothAction`; `In<'a> = (SmoothResourceId, &'a [u8])`.
  - **`dash_pull`'s in-read-path sleep is gone.** `maybe_refresh_mpd` buried
    a wall-clock `Instant::elapsed` + `tokio::time::sleep` inside what was
    nominally a "compute the next samples" step — a sans-IO session that
    sleeps internally is not sans-IO. The live-MPD refresh now goes through
    `Stage::next_deadline`/`on_deadline` (an absolute `Timestamp` on the
    driver's clock, exactly like `HandshakePolicy::establish_by`), and
    `run_dash_pull`'s own loop is the only place a clock is read or a sleep
    awaited. `smooth_pull`'s identical `maybe_refresh_manifest` sleep got the
    same treatment.
  - **`dash_pull`'s three pieces of per-Representation state, judged
    individually** (round-2 flagged them as possible `Trunk` duplication;
    all three are kept, with reasons recorded in the module doc):
    - `RepState::init_bytes`**kept.** Genuinely per-source parsing state:
      the `Trunk` stores decoded `Sample`s, never raw container bytes, so
      there is nothing to duplicate. Re-concatenating the cached init onto
      every `moof`+`mdat` media segment is a structural requirement of DASH's
      own wire format (`Fmp4Demux::unpackage` needs the `moov` in the same
      buffer), not a cache of plane state.
    - `RepState::plan`**kept.** Fetch *scheduling* state (what to request
      next), a concept the `Trunk` does not have at all — it only ever sees
      samples after they arrive.
    - `RepState::last_number`**kept.** The high-water mark that stops a
      live-MPD refresh re-enqueueing an already-planned segment number;
      again a fact about *pending fetches*, recorded nowhere else in the
      pipeline.
  - **In-flight fetches are bounded.** New `source::MAX_INFLIGHT_FETCHES`
    (8): a pull session can hand back many `poll_transmit` requests in one
    drain (an LL-HLS playlist reload revealing a dozen available parts at
    once; a manifest refresh extending several Representations' plans
    simultaneously) with nothing in the session capping how many the driver
    launches concurrently. Each `run_*_pull` loop gates every `JoinSet::spawn`
    on that cap and queues the rest — this project has already shipped five
    unbounded-allocation vectors in remote-input-driven code, and an uncapped
    fan-out of open sockets per route is the same class of bug.
    `dash_pull`/`smooth_pull` additionally keep at most one fetch per
    Representation/StreamIndex outstanding (`in_flight`), preserving the
    pre-port per-round pacing.
  - `origin::serve_with_registry`'s `HlsPull`/`DashPull`/`SmoothPull` arms
    join the existing step-5a stub arm (an explicit `tracing::error!`)
    alongside `Rtsp`/`Rtp`/`TsUdp`/`TsHttp`/`Srt`, and their
    `SourceConnector`/`SampleSource` impls are removed. `rtmp` is now the
    only source still on the pre-5a `run_pipeline` path, which is therefore
    still load-bearing for exactly one input kind.
- New `MultimuxError::LlHls` variant wrapping `ll_hls_runtime::client::Error`
  (a malformed playlist, a demux failure, or a resource fed for an id the
  client never requested).

### Changed (BREAKING, in progress — plan step 5a, round 2)
- **Ported `ts_http` and `srt` onto the plane's ingress traits**, and
  **extracted the shared `source::ts_program::TsIngestSession`** — one copy
  of the `StreamingTsDemux``SessionEvent` translation (including the B5
  mid-stream `NewProgram` handling), now shared verbatim by `ts_udp`,
  `ts_http` and `srt`. Those three previously carried three near-identical
  ~60-line drain loops that had already drifted.
  - `TsHttpSource`/`TsHttpSession``TsHttpRoute`/`TsHttpDialer` +
    `open_stream`/`recv_and_feed`/`run_ts_http`.
  - `SrtSource`/`SrtSession``SrtRoute`/`SrtDialer` +
    `connect_caller`/`accept_listener`/`drive_socket`/`run_srt_caller`/
    `run_srt_listener_once`.
  - `ts_http` is the first ported source that produces **both**
    `HealthState::Ended` (a cleanly-finished HTTP body) and
    `HealthState::Failed` (a read stall) — the EOF≠error distinction step 3c
    made producible is now actually produced, and mutation-checked.
  - All 5 of `ts_http`'s Basic/Digest/Bearer/override/wrong-credential auth
    tests are preserved, retargeted at the new API.

### Findings from this round (design-level, not defects)
- **The segmenter gap is CLOSED** by `Trunk::segment_writer()`. Proven, not
  asserted: `source::ts_program`'s
  `a_segmenter_can_hold_a_segment_writer_while_ingest_holds_the_sample_writer`
  drives real muxed TS → `TsIngestSession``IngestDriver` (holding the
  `TrunkWriter`) → `SampleCursor` → a real `LlHlsSegmenter` → a
  `SegmentWriter` taken from the **same** `Trunk`, and asserts the segments
  land in `Trunk::last_closed_segment`/`segment_len`. It also asserts
  `trunk.writer().is_none()` so the test cannot silently degrade into "a
  `Trunk` nobody else was writing to". `MOVIE_TIMESCALE` is now a parameter
  of the segmenter component rather than a hardcoded `run_pipeline`
  constant, which is what makes it per-route-configurable at all; **which**
  component owns the segmenter is step 5b's call, since egress consumes
  segments.
- **The pull sources DO need a plane change** — this refutes round 1's
  answer ("a concrete per-source method suffices"). `ll-hls-runtime`
  *already* ships the request-addressing type 3c predicted: `LlHlsClient` is
  a sans-IO engine with `poll() -> Option<Action>`, `on_playlist(&[u8])`,
  `on_resource(ResourceId, &[u8])`, `on_error(Option<ResourceId>)`.
  The blocker is not the *request* side, which `poll_transmit` could be
  widened to carry — it is the **response** side: `Stage::feed(&[u8])` is a
  single, uncorrelated input, while a pull source has N in-flight requests
  whose responses must be routed back **by identity** (`ResourceId`) and
  arrive out of order. There is no honest way to express "these bytes are
  the response to `Part{msn:5,part:2}`" through `feed(&[u8], now)`.
  - **Minimum shape** (recorded, not implemented — it is a `media-plane`
    change with its own blast radius): relax `IngestSession`'s pin of
    `Stage::In` from `&'a [u8]` to the implementor's choice, and add an
    associated request type:
    ```rust
    pub trait IngestSession: for<'a> Stage<Out = SessionEvent> + Send {
        type Request: Send;
        fn poll_transmit(&mut self) -> Option<Self::Request>;
    }
    ```
    Stream sources keep `In<'a> = &'a [u8]`, `Request = Bytes` — no
    behaviour change. A pull source sets `In<'a> = (ResourceId, &'a [u8])`,
    `Request = Action`. `IngestDriver::feed` becomes
    `feed(&mut self, input: S::In<'_>, now)`, which is still fully generic.
    Associated types cannot have defaults on stable, so every implementor
    gains one line (`type Request = Bytes;`).
  - `hls_pull`/`dash_pull`/`smooth_pull` are therefore **deliberately not
    ported**. Contorting them into the current trait would mean a session
    whose `Stage::feed` is never called (all real input arriving through
    out-of-band `on_resource` calls made by the driver), i.e. a type that
    lies about the contract it implements.
  - Separately, `dash_pull` holds three pieces of state a segment store
    would duplicate (`RepState::init_bytes`, re-concatenated onto every
    segment; `plan`; `last_number`) and buries a wall-clock
    `Instant::elapsed` + `tokio::time::sleep` inside its read path
    (`maybe_refresh_mpd`) — a second, independent obstacle to a sans-IO
    port, which wants `next_deadline`/`on_deadline` instead.
- **`rtmp` and `srt`-listener: scoped estimate, not a half-port.**
  - **RTMP is genuinely cheap and worth doing.** `rtmp_runtime::server::ServerSession`
    is *already* exactly the right sans-IO shape:
    `handle_data(&[u8]) -> Result<(Vec<u8>, Vec<ServerEvent>)>` — bytes in,
    (reply bytes, events) out, buffering partial handshakes/chunks
    internally. That maps onto `Stage::feed` + `poll` + `poll_transmit` with
    no new machinery, and `rtmp_runtime::io` is a ~246-line adapter (half
    tests) that would mostly disappear. `AsyncRtmpServer::accept()` is
    `&self` (no `Mutex` needed) and a *pure* TCP accept exchanging zero
    protocol bytes. **Estimate: the session half is ~1 day** (close to a
    transcription of `RtmpConnection::next_events`). **The `Listener` half
    needs one small upstream addition** — a non-blocking
    `AsyncRtmpServer::poll_accept()` (a `TcpListener::poll_accept` wrapper,
    a few lines in `rtmp-runtime`), after which `Listener{max_sessions}` +
    `ListenDriver` fit with no further redesign. Total ≈ 1.5–2 days.
  - **SRT-listener is substantially harder and should be sequenced after a
    `srt-runtime` change.** `SrtListener::accept` is a blocking `.await`
    only (no `poll_accept`/`try_accept`) and takes `&mut self`; more
    fundamentally the listener and *every* accepted connection share one
    `UdpSocket` (`drain_completed` hands each new connection an
    `Arc::clone`), so "accept another while N are live" is a demultiplexing
    responsibility **inside `srt-runtime`**, not something `multimux` can
    arrange. `srt-runtime` does ship a full sans-IO core
    (`CallerHandshake`/`ListenerHandshake`/`arq`/`tsbpd`/`livecc`) but hides
    it behind a private `Driver` task; `SrtSocket` is only a pair of `mpsc`
    channels, so there is **no public "feed a datagram in, get a payload
    out" connection type**. **Estimate: ~3–5 days in `srt-runtime`**
    (expose a sans-IO connection + a pollable listener owning the shared
    socket demux), then ~1 day in `multimux`. Until then `srt`-listener
    keeps today's accept-one-serially semantics via
    `run_srt_listener_once`, which is documented rather than disguised.

### Changed (BREAKING, in progress — plan step 5a)
- **Ported `rtsp`, `rtp_udp`, and `ts_udp` onto `media_plane::ingress`'s
  `Dialer`/`IngestSession`/`IngestDriver`** (plan step 5, "port the 9 ingest
  sources" — `docs/superpowers/plans/2026-07-26-media-plane-implementation.md`):
  - `RtspSource`/`RtspSession``RtspRoute`/`RtspDialer`/`RtspIngestSession`
    + `run_rtsp`. `RtspDialer::dial` performs **no I/O** — it wraps
    `rtsp_runtime::client::ClientSession` (already sans-IO) directly, so the
    whole DESCRIBE→SETUP(×N)→PLAY handshake completes through the ordinary
    `poll_transmit`/`feed` pump, confirming `media_plane::ingress`'s central
    design bet for a protocol with a pre-existing sans-IO engine. `rtsps://`
    (TLS) is not yet wired into `run_rtsp`'s driver loop (scope cut, not a
    design gap — see the module doc).
  - `TsUdpSource`/`TsUdpSession``TsUdpRoute`/`TsUdpDialer`/`TsUdpIngestSession`
    + `run_ts_udp`. Fixes issue #774's silent-drop of a `DemuxEvent::TrackAdded`
    declared after the initial PMT resolution: it now mints a **new**
    `ProgramId` (media-plane finding B5) instead of being logged and
    dropped.
  - `RtpUdpSource`/`RtpUdpSession``RtpUdpRoute`/`RtpUdpDialer`/`RtpUdpIngestSession`
    + `run_rtp_udp`.
  - All three publish raw samples straight into a `media_plane::Trunk` via
    `IngestDriver`, replacing their own hand-rolled demux-drain loops.
  - **Not yet wired into `origin::serve_with_registry`/`origin::supervisor::supervise`**:
    that loop is built on `MediaStore`/`HealthState`, which plan step 4
    deleted from `ll-hls-runtime` (the crate does not currently build — see
    the 4 `ll_hls_runtime::server::{HealthState,MediaStore,PlaylistOutcome,
    ResourceOutcome,media_playlist_m3u8}` import errors, all pre-existing
    and unrelated to this change) — rewiring these three routes needs step
    5b's `Trunk`-backed replacement, not just a source rename; the affected
    `InputSpec` match arms are stubbed with a clear "not yet wired" log line
    rather than left silently referencing removed types.
  - **Not ported** (superseded by round 2 above, which added `ts_http` and
    `srt`): `rtmp`, `hls_pull`, `dash_pull`, `smooth_pull` remain on the
    pre-5a `SampleSource`/`run_pipeline`/`SourceConnector` path, which stays
    in place (`crate::pipeline`/`crate::origin::supervisor`) for exactly
    those four. See "Findings from this round" for why each is deferred.
  - `crate::pipeline::SampleSource`/`run_pipeline` and
    `crate::origin::supervisor::SourceConnector` are **not** deleted this
    pass (still load-bearing for the six not-yet-ported sources); only the
    three ported sources' `impl`s were removed from them.

### Fixed
- **Cleared this crate's share of the latest-stable clippy canary** (issue
  #770 — the non-blocking `clippy (latest stable)` CI job, which had been
  failing on `main` unnoticed across many merges). Both changes are
  behaviour-preserving; no `#[allow]` was added.
  - `source::rtsp::RtspClient` (a private enum) now boxes **both** its
    `Plain` and `Tls` variants (`clippy::large_enum_variant`). The unboxed
    TLS variant carried rustls connection state at ~1472 bytes against the
    plain client's ~408, so every `RtspClient` — and the `RtspSession`
    embedding it — was sized for TLS even on a plain `rtsp://` connect.
    Boxing only the larger variant merely flips the lint onto the other one,
    so both are boxed and the enum is now pointer-sized. The single
    allocation happens once per connect, never on the per-packet
    `recv_interleaved` path.
  - `source::sdp::parse_sdp_tracks` derives its 1-based `track_id` from the
    media-order iterator instead of a hand-rolled counter
    (`clippy::explicit_counter_loop`). The interleaved `channel` keeps its
    own explicit `saturating_add(CHANNEL_STEP)` stepping — it advances by 2,
    not 1, and the saturating behaviour at the `u8` ceiling is deliberate.

## [0.4.0] - 2026-07-26

### Added
- **RTMP push ingest input `InputSpec::Rtmp`** (issue #738, via
  `rtmp-runtime`'s sans-IO ingest server + `transmux::StreamingFlvDemux`):
  a route can now accept an inbound RTMP publisher (`ffmpeg`/OBS-class
  encoders) instead of only pulling from a source — `RtmpSource` binds its
  `listen` address once and reuses it across reconnects, accepts a
  publisher, and drives its FLV byte stream through the same
  segmenter/`MediaStore` pipeline every other input uses. Optional `app`/
  `stream_key` gate which publishers are accepted.
- **`HlsPullSource` now ingests classic MPEG-TS-segment HLS** (issue #760,
  via `ll-hls-runtime` 0.1.x's new `LlHlsClient` TS routing): pulling a
  legacy/IPTV origin whose Media Playlist carries no `EXT-X-MAP` (whole
  `.ts` segments, no init resource) now works end to end — `connect()`
  recovers real `TrackSpec`s from the client's synthesized `Output::Init`
  and `next_samples()` yields every real access unit, with no TS-specific
  code needed in this crate at all.
- **DASH-pull ingest input `InputSpec::DashPull`** (issue #758, via
  `transmux`'s hand-rolled MPD parser, `transmux::dash_parse`): a route can
  now pull a remote MPEG-DASH presentation — fetch + parse the MPD, resolve
  each selected Representation's `SegmentTemplate`/`SegmentTimeline`
  init+media segment URLs, and demux the fetched fMP4/CMAF bytes via
  `transmux::Fmp4Demux` (each media segment concatenated onto its
  Representation's cached init bytes, matching `ll-hls-runtime`'s own
  CMAF-part demux pattern), remapping each Representation's local track_id
  to a session-wide unique id. Supports `$Number$`/`$Time$` addressing, fMP4,
  and both static and dynamic (live, MPD-refresh) presentations;
  `SegmentList`/`SegmentBase` addressing is deferred. Every network step is
  bounded by `IngestTimeouts`, so a stalled/unreachable DASH origin cannot
  wedge the route.
- **Smooth-pull ingest input `InputSpec::SmoothPull`** (issue #759, via
  `transmux`'s hand-rolled MS-SSTR client-Manifest parser,
  `transmux::smooth_parse`): a route can now pull a remote Microsoft Smooth
  Streaming presentation — fetch + parse the client Manifest, resolve each
  `StreamIndex`'s fragment-URL template, and demux the fetched fragments via
  `transmux::Fmp4Demux`. Smooth carries no bootstrapping init segment, so
  `SmoothPullSource` synthesizes one per stream from
  `QualityLevel@CodecPrivateData` (`track_spec_from_quality_level` +
  `build_init_segment`), discovering and matching each stream's wire
  `track_id` from its first fragment's `tfhd` (no `moov` to read one from,
  unlike DASH) and remapping it to a session-wide unique id. Supports
  static and dynamic (live, manifest-refresh) presentations. Every network
  step is bounded by `IngestTimeouts`, every expected track is resolved
  before the route starts, and a PlayReady/PIFF sample-encrypted source
  (`<Protection>` manifest element, or CENC/PIFF sample-encryption boxes in
  a fragment) fails with a clear typed `MultimuxError::Encrypted` instead of
  silently demuxing garbage — decrypting Smooth-protected content is out of
  scope.
- **SRT ingest input `InputSpec::Srt`** (issue #739, via `srt-runtime`'s
  real-socket `SrtListener`/`SrtSocket` adapter): a route can now ingest an
  SRT-carried MPEG-2 Transport Stream in either listener mode (binds once
  and accepts inbound Callers, reused across reconnects — like
  `InputSpec::Rtmp`'s push pattern) or caller mode (dials out fresh on every
  reconnect). The track set comes from the stream's own in-band PMT via
  `transmux::StreamingTsDemux`, exactly like `InputSpec::TsUdp`. Encrypted
  SRT is out of scope (no passphrase field).

## [0.3.1] - 2026-07-21

### Changed
- **True chunked-transfer LL-DASH** (issue #721): `LlDashOutput`'s
  `manifest-ll.mpd` now renders a real chunked-CMAF MPD via
  `transmux::LlDashPackager` — a whole-segment `SegmentTemplate` (the same
  `seg-{track}-{seq}.m4s` addressing `manifest.mpd` uses) with a genuinely
  non-zero `availabilityTimeOffset`/`availabilityTimeComplete="false"`  replacing the previous discrete-parts-signalling fallback (which
  re-addressed `part-*.m4s` files directly and could only claim an honest
  `availabilityTimeOffset="0"`). The shared origin resource route now serves
  a not-yet-closed segment over **HTTP chunked transfer-encoding**,
  streaming the store's live parts as they land and completing once the
  segment closes; a closed segment is served exactly as before (whole
  bytes, `Content-Length`). Because whole closed segments stay in the
  store's window, `manifest-ll.mpd` now also advertises a real
  `timeShiftBufferDepth`. Validated against a real headless dash.js
  low-latency player (`multimux/tests/lldash_dashjs.rs`, vendored
  `dash.all.min.js` 5.2.0 under `multimux/tests/assets/`): real playback
  advances past 1.8s with measured live latency below the 1s segment
  target, proving genuine chunked (not whole-segment) availability.

## [0.3.0] - 2026-07-21

### Added
- **External scheme plugin registry** (issue #663): a third-party crate can
  now add a new input, output, or output-auth scheme to the multimux origin
  **without editing multimux**, wired purely via config JSON. Built-ins
  (RTSP/RTP/TS-UDP/TS-HTTP/HLS-pull inputs; LL-HLS/DASH/LL-DASH outputs;
  Basic/Digest/Bearer/Forwarded output-auth) are unchanged — the typed,
  validated fast path. Extension is additive:
  - New `Custom { type_tag, params }` variants on `config::InputSpec` (JSON
    `{ "type": "custom", "type_tag": "...", "params": { ... } }`),
    `output::OutputKind` (JSON `{ "custom": { "type_tag": "...", "params":
    { ... } } }` — not internally tagged like the other two, since the other
    `OutputKind` variants are plain strings), and `config::OutputAuthSpec`
    (JSON `{ "scheme": "custom", "type_tag": "...", "params": { ... } }`).
    `params` is an opaque `serde_json::Value`, always structurally valid at
    `Config::validate` time — the registered factory validates it, at
    route-build time. Every `Custom` variant's hand-written `Debug` shows
    `type_tag` but always redacts `params` as `"<params>"` (it may carry an
    external scheme's credentials).
  - A new `registry::SchemeRegistry` — built by the embedding application,
    never by multimux itself — mapping each `type_tag` to a factory closure
    that builds the real thing from the opaque `params`: `register_input`/
    `register_output`/`register_auth` (and their `input`/`output`/`auth`
    lookups). `InputFactory` closures construct their own concrete
    `SourceConnector` and spawn `supervise` themselves (returning its
    `JoinHandle`) rather than returning a connector — `SourceConnector` is
    not object-safe, so this is how a factory erases the connector type;
    `OutputFactory`/`AuthFactory` return `Arc<dyn output::Output>`/
    `broadcast_auth::Verifier` directly (both already concrete/object-safe).
  - `origin::serve_with_registry(config, registry)``origin::serve(config)`
    is now `serve_with_registry(config, SchemeRegistry::new())`. An
    unregistered `Custom` `type_tag` fails route setup with the new
    `MultimuxError::UnknownScheme { kind, tag }` (`kind` is `"input"`,
    `"output"`, or `"auth"`) rather than panicking or silently no-opping.
  - New re-exports at the crate root for external factory authors:
    `SchemeRegistry`, `InputCtx`/`OutputCtx`/`AuthCtx`,
    `InputFactory`/`OutputFactory`/`AuthFactory`, `serve`/
    `serve_with_registry`, `supervise`/`SourceConnector`/`Backoff`,
    `Source`, `MediaStore`, `Output`, and the `broadcast_auth` crate itself
    (so a registered `AuthFactory` can build a `Verifier` without an
    external crate needing its own direct dependency on `broadcast-auth`).
  - New example `examples/custom_scheme.rs`: registers a custom input
    scheme with zero multimux edits.

### Changed (breaking)
- **`output::OutputKind` no longer derives `Copy`/`PartialEq`/`Eq`/`Hash`**
  (only `Debug`/`Clone`/`Deserialize`/`Serialize` remain): its new `Custom`
  variant carries a `serde_json::Value`, which is `Clone` but not `Copy`.
  Compare `OutputKind` values via `.name()` or `matches!` instead of `==`.
  `OutputKind::name()`'s return type changed from `&'static str` to `&str`
  (`Custom` labels itself by its own `type_tag`, borrowed from `self`).

### Added
- **`OutputAuthSpec::Forwarded` — reverse-proxy forwarded-auth output-auth
  scheme** (issue #663 extensibility wave part 1, built on
  `broadcast_auth::Verifier::forwarded`): configures the shared output-auth
  gate to trust a fronting reverse proxy that has already authenticated the
  caller, rather than checking a Basic/Digest/Bearer credential itself.
  JSON: `{ "scheme": "forwarded", "user_header": "X-Forwarded-User",
  "forwarded_for_header": "X-Forwarded-For" }` — both fields optional,
  defaulting to `X-Forwarded-User`/`Some("X-Forwarded-For")`; set
  `forwarded_for_header: null` to disable reading it. A request is allowed
  iff `user_header` is present and non-empty; `forwarded_for_header`, if
  set, is read for tracing/observability only — no trust decision is made
  from it. **Safe ONLY behind a trusted reverse proxy that strips any
  client-supplied copies of both headers before forwarding** — see
  `OutputAuthSpec::Forwarded`'s doc comment. `output_auth_gate` now builds a
  `broadcast_auth::RequestContext` carrying every request header (not just
  `Authorization`) plus the transport peer address (via
  `into_make_service_with_connect_info`, wired in `serve`), so any
  `Verifier` scheme — this one included — can see beyond `Authorization`.
- **`InputSpec`/`AuthSpec`/`OutputAuthSpec`/`output::OutputKind` are now
  `#[non_exhaustive]`** (issue #663 extensibility wave part 1): a future
  ingest transport, client-auth scheme, output-auth scheme, or delivery
  protocol can be added later without it being a breaking change for
  external matches on these types.

### Changed (breaking)
- **`OutputAuthSpec::to_credentials` replaced by
  `OutputAuthSpec::build_verifier(realm)`** (`pub(crate)`, so only affects
  this crate's own `serve`): returns the configured
  `broadcast_auth::Verifier` directly rather than a `Credentials` value —
  needed because `Forwarded` has no `Credentials` mapping at all (no
  username/password/token, no challenge/response round-trip).

### Added
- **Shared output auth** (issue #663 "shared output auth",
  `docs/superpowers/specs/2026-07-18-multimux-hub-design.md`): one
  Basic/Digest/Bearer credential can now gate **every** media output route
  (`/{stream}/…` — manifests and init/segment/part bytes alike, across every
  configured route, e.g. 40 cameras under `/camN/index.m3u8`) via a new
  `Config::output_auth` (`config::OutputAuthSpec`, JSON tagged on `scheme`:
  `{ "scheme": "basic"|"digest"|"bearer", ... }`). Independent of, and
  unrelated to, each route's own ingest auth (`config::AuthSpec`/URL
  userinfo) — one output credential guards the whole origin regardless of how
  differently each camera authenticates upstream. Built on the new
  `broadcast_auth::Verifier` (the server-side challenge+verify half,
  promoted out of `testutil`'s test-only mock — see that crate's changelog).
  Missing/wrong credentials get `401` + `WWW-Authenticate` (Basic/Digest
  challenge, or the bare `Bearer` token for Bearer); `output_auth: None` (the
  default) leaves every route open, unchanged from pre-#663 behaviour.
  **Ops endpoints (`/healthz`/`/readyz`/`/metrics`) are never gated** — load
  balancer probes and metrics scraping stay open regardless of
  `output_auth`. CORS/`Cache-Control` headers still apply to a `401`
  response from this gate (needed for a cross-origin browser client to see
  the status/challenge at all, not just a successful response).
- **Configurable `playlist_name`** (issue #663 "configurable
  `playlist_name`"): a new `Config::playlist_name` (default `"media.m3u8"`)
  names the LL-HLS media playlist filename served at
  `/{stream}/{playlist_name}`; `master.m3u8`'s `#EXT-X-STREAM-INF` reference
  follows suit (`output::llhls::LlHlsOutput::new`). Validated non-empty,
  `.m3u8`-suffixed, slash-free, and not `"master.m3u8"` (which would collide
  with the fixed master-playlist route). `master.m3u8`'s own name is not
  configurable; DASH's `manifest.mpd` is unaffected. Breaking (internal):
  `LlHlsOutput` is no longer a unit struct — use `LlHlsOutput::default()` (or
  `OutputKind::build()`) for the pre-existing `/media.m3u8` behaviour, or
  `LlHlsOutput::new(name)`/`OutputKind::build_with_playlist_name(name)` for a
  configured name; `output::llhls::{master_playlist, media_playlist}` are
  narrowed from `pub` to `pub(crate)` (their `State` type changed shape, and
  nothing outside this crate called them directly). Depends on
  `ll_hls_runtime::server::master_playlist_m3u8` now taking the media
  playlist's filename as an argument (see that crate's changelog).
- **RTSP config-auth (`with_auth`) Digest coverage against a real server**
  (the gap flagged in the client-auth story): a new loopback test drives
  `source::rtsp::RtspSource` with config-supplied (not URL-userinfo) Digest
  credentials against a mock server verified by the real
  `broadcast_auth::Verifier`, proving the `with_auth` -> `ClientSession`
  wiring end-to-end (success and wrong-password cases), mirroring
  `rtsp-runtime/tests/io_loopback.rs::digest_auth_over_loopback`.
- **Config-supplied + Bearer credentials, finishing client-side
  multi-scheme auth** (issue #663 — completes the P3c "Shared auth layer"
  story): `InputSpec::Rtsp`/`TsHttp`/`HlsPull` each gained an optional
  `auth` field (`config::AuthSpec` — either `{ username, password }` or
  `{ bearer_token }`), config-parseable via `--config <FILE>`. A Bearer
  token has no URL-userinfo form, so this is the *only* way to supply one;
  when both a config `auth` and URL userinfo are present, config wins
  (`source::http_auth::resolve_credentials`, now used by `RtspSource`,
  `TsHttpSource`, and `HlsPullSource` alike, each via a new
  `with_auth(Option<Credentials>)` builder mirroring `with_timeouts`).
  `AuthSpec`'s `Debug` redacts both `password` and `bearer_token`;
  `Config::validate` rejects an empty `username`/`bearer_token` (an empty
  `password` is accepted — some devices genuinely use one). Every
  pre-existing config still parses unchanged (`#[serde(default)]`).
  - **Digest/Basic/Bearer now proven end-to-end**, not just unit-tested in
    isolation: a new test-only mock auth server (`testutil`, gated
    `#[cfg(test)]`) gates a real axum router behind any of the three
    schemes — Digest verification is a real, independent RFC 7616 §3.4.1
    computation (not a literal-string match), so a client with the wrong
    password genuinely gets rejected. `source::ts_http` and
    `source::hls_pull` each gained Basic/Digest/Bearer/wrong-credentials
    tests driving the real `TsHttpSource`/`HlsPullSource` against it, plus a
    `config_auth_overrides_wrong_url_userinfo` precedence test.
  - No change needed to answer Digest's re-challenge-on-every-request
    concern: `ll_hls_runtime::client::tokio_client::TokioClient` already
    caches its Digest `Authenticator` across requests (from P3c), and
    `TsHttpSource`'s streaming GET only ever makes one request per
    `connect()`, so there was nothing further to cache there.
- **DASH output alongside LL-HLS, from the same shared CMAF segments**
  (issue #663 P4 — `docs/superpowers/specs/2026-07-18-multimux-hub-design.md`,
  "DASH output"): one ingested stream can now serve LL-HLS *and* MPEG-DASH
  simultaneously, both reading the exact same `MediaStore`-produced
  init/segment bytes (ingest-once, many-outputs — no per-output re-mux).
  - **The multi-output nest collision fix (the load-bearing refactor)**: two
    `Output`s each mounting their own `/:file` catch-all under the same
    `/{stream}` nest previously panicked axum. Fixed by splitting
    responsibilities: the `Output` trait's `router` method is now
    `manifest_routes` — each output contributes *only* its manifest
    route(s) (`master.m3u8`+`media.m3u8` for LL-HLS, `manifest.mpd` for
    DASH) — while the init/segment/part byte serving (`init-*.mp4`/
    `seg-*.m4s`/`part-*.m4s`, protocol-neutral since both outputs are
    fMP4/CMAF) moves to a new shared `origin::resource` route, mounted
    **once per stream** by `origin::router` (merging every output's
    manifest routes with the one shared resource route, then `nest`ing the
    merged router once — instead of nesting per-output). LL-HLS's URLs and
    behaviour (routes, blocking reload, `Cache-Control`/CORS policy) are
    unchanged; the shared `Cache-Control`/CORS middleware (generalized to
    treat `.mpd` the same as `.m3u8`) now lives at the origin level so it
    covers the shared resource route too.
  - `output::dash::DashOutput`: renders a live (`type="dynamic"`) MPD via
    `transmux::dash::DashPackager`, `$Number$`-addressed `SegmentTemplate`
    (not `$Time$`/`SegmentTimeline` — the store's `seg-{track}-{seq}.m4s`
    filenames are sequence-numbered, not time-addressed, so `$Number$` is
    the only mode whose URIs the shared resource route actually resolves),
    with `minimumUpdatePeriod`/`timeShiftBufferDepth`/
    `availabilityStartTime` derived from the store's target duration/window/
    construction time. Single-rendition model matching LL-HLS's own
    `DEFAULT_TRACK_ID` convention: the `Representation`'s `@id` is forced to
    `DEFAULT_TRACK_ID` regardless of the source's own track numbering, so
    `$RepresentationID$` substitution produces the same `init-1.mp4`/
    `seg-1-<N>.m4s` filenames LL-HLS already references. **True chunked-CMAF
    LL-DASH (`transmux::LlDashPackager`/`LlSegmenter`) is not implemented**
    — the store's `part-*.m4s` files are LL-HLS-shaped, not CMAF byte-range
    chunks; P4.2 below ships a signalled-MPD LL-DASH output addressing those
    existing parts instead, with true chunked transfer tracked as P4.3.
  - `ll_hls_runtime::server::MediaStore` gained the accessors a DASH
    renderer needs beyond LL-HLS's own bytes+timing: `set_track_specs`/
    `track_specs` (recorded once by `pipeline::run_pipeline` so DASH can
    build a real RFC 6381 `codecs` string), `window_segments` (a
    protocol-neutral snapshot of the closed-segment window), `created_at`
    (the live presentation's `availabilityStartTime` anchor); the previously
    crate-private `target_duration_secs`/`part_target_ms` accessors are now
    `pub` for the same cross-`Output` reason.
  - Config: `config::Route::outputs: Vec<output::OutputKind>` selects which
    protocol(s) to serve a route as (`"llhls"`/`"dash"`, per-route rather
    than a single global default — different routes may reasonably want
    different output sets), defaulting to LL-HLS only so every existing
    config is unaffected. `Config::validate` rejects an empty `outputs`
    list. `multimux-cli` gained `--outputs llhls,dash` (and the `--dash`
    shorthand for "both") on the single-route quick start.
- **LL-DASH output (low-latency DASH signalling)** (issue #663 P4.2 —
  `docs/superpowers/specs/2026-07-18-multimux-hub-design.md`, "LL-DASH"): a
  new `output::ll_dash::LlDashOutput`/`OutputKind::LlDash` (`"ll_dash"`)
  renders `manifest-ll.mpd`, an LL-DASH-**signalled** MPD carrying
  `availabilityTimeOffset`, `<ServiceDescription><Latency target="…"/></ServiceDescription>`
  (ISO/IEC 23009-1 §5.13.2), and a `minimumUpdatePeriod` tuned to the part
  target — served at its own path (not a mode flag on `manifest.mpd`) so a
  route can enable `dash` (DVR) and `ll_dash` (live edge) together.
  - **Scope decision: discrete-parts signalling, not true chunked-transfer
    LL-DASH.** As flagged by P4's own follow-up note, the store's
    `part-*.m4s` files are LL-HLS-shaped (a whole extra fMP4 `moof`+`mdat`
    per part), not CMAF byte-range chunks within one in-progress segment —
    wiring `transmux::LlDashPackager`/`LlSegmenter` for *true* chunked
    delivery needs a second, chunk-shaped segmenter output, a larger lift
    than this story's scope. Instead, `LlDashOutput` re-addresses the
    **existing** parts: its `SegmentTemplate` uses `$Number$` addressing
    with `@duration` = the real part target (not the whole-segment target),
    `startNumber="0"`, and a media template that bakes the in-progress
    segment's sequence number in as literal text (refreshed on every
    fetch — the MPD is always `type="dynamic"`, never cached) around the
    real `$RepresentationID$`/`$Number$` tokens, so a real client's
    substitution produces exactly the `part-{track}-{seq}.{idx}.m4s`
    filenames the shared resource route already serves for `ll_hls`. This
    covers **only the live edge** (no `timeShiftBufferDepth` — an absent
    value is spec-honest "unknown", not a fabricated DVR window this
    origin cannot serve); pair with `dash`'s `manifest.mpd` for seek-back.
    `availabilityTimeOffset` is honestly `"0"`: a part is produced
    atomically (never partially available), so the low-latency win here
    comes from the small nominal segment(=part) duration, not partial
    delivery — reusing `transmux::LlDashPackager`'s `segment − chunk`
    formula would misrepresent that, so this module hand-rolls its own
    small `<ServiceDescription>`/`availabilityTimeOffset` XML injection
    instead. True chunked-transfer CMAF remains tracked as **P4.3**.
  - `ll_hls_runtime::server::MediaStore::latest_progress` (the in-progress
    segment's sequence number + live part count) is now `pub` (was
    `pub(crate)`) for the same cross-`Output` reason as `window_segments`/
    `track_specs` before it.
  - Config: `outputs: ["llhls", "dash", "ll_dash"]` is now accepted;
    `Config::validate`/serde behave the same as any other `OutputKind`
    (unknown tokens rejected, empty `outputs` rejected).
- **Generalized input model + UDP-family ingest** (issue #663 P3a —
  `docs/superpowers/specs/2026-07-18-multimux-hub-design.md`, "Input
  adapters"): a route's ingest transport is now a tagged `config::InputSpec`
  (`Rtsp { url }` / `Rtp { addr, sdp, multicast_group }` / `TsUdp { addr,
  multicast_group }`, `#[serde(tag = "type", rename_all = "snake_case")]`),
  replacing the RTSP-only `Route::rtsp_url` field — a **breaking config
  change**: JSON routes now nest under `"input": { "type": "rtsp", "url":
  ... }` instead of a bare `"rtsp_url"` key. `origin::serve` dispatches each
  route to the matching `SourceConnector` with one `match` arm per
  `InputSpec` variant (kept monomorphized, not boxed, since each connector's
  `Source` associated type differs) — reconnect/backoff/health via
  `origin::supervisor::supervise` applies identically to every input kind.
  - `source::rtp_udp::RtpUdpSource` — raw RTP over UDP (uni/multicast, no
    RTSP control plane): binds a `tokio::net::UdpSocket` (+ optional
    multicast join via the new `source::udp::bind_udp` helper shared with
    `TsUdpSource`), parses the configured out-of-band SDP with the *same*
    `source::sdp::parse_sdp_tracks` RTSP already uses (no parallel SDP
    implementation), and depayloads with `transmux::RtpStreamDepacketiser`
    exactly as `source::rtsp::RtspSession` does. Since raw RTP/UDP has no
    RTSP interleaved-channel framing, incoming packets are routed to their
    track by RTP payload type (RFC 3550 §5.1) matched against each SDP
    media's declared payload type — `source::TrackInit` gained a
    `payload_type` field (populated identically for both the RTSP and raw-RTP
    ingest paths, since both share `parse_sdp_tracks`) and
    `source::sdp::load_sdp` loads an SDP body from either inline text or an
    `@path` file reference (re-read on every reconnect).
  - `source::ts_udp::TsUdpSource` — MPEG-2 Transport Stream over UDP
    (uni/multicast): binds the same shared UDP transport, then feeds
    datagrams to `transmux::StreamingTsDemux` (the same streaming demux core
    every other TS consumer in this workspace drives) until the in-band PMT
    resolves every declared track (bounded by a 10 s connect timeout) — the
    TS-over-UDP analogue of RTSP's DESCRIBE step — before the pipeline builds
    its segmenter.
  - No new codec/container parsing in multimux: both sources are transport
    (socket bind + multicast join) plus wiring over transmux's existing
    `RtpStreamDepacketiser`/`StreamingTsDemux`.
  - `Config::validate` now validates every route's `InputSpec` fields (RTSP
    scheme, UDP address parseability, multicast-group IP validity, RTP SDP
    non-empty/parseable) in addition to the existing duplicate-name/timing
    checks.
- **HTTP-based ingest: TS-over-HTTP + HLS-pull** (issue #663 P3c / #717 —
  `docs/superpowers/specs/2026-07-18-multimux-hub-design.md`, "Input
  adapters" / "Shared auth layer"): two new `InputSpec` variants,
  `TsHttp { url }` and `HlsPull { url }`, both `http(s)://` URLs that may
  carry `user:pass@` userinfo (redacted in `Debug`, validated for scheme by
  `Config::validate`).
  - `source::ts_http::TsHttpSource` — MPEG-2 Transport Stream over a
    streaming HTTP GET (chunked/progressive `reqwest`, `stream` feature):
    reads response chunks into `transmux::StreamingTsDemux` until the
    in-band PMT resolves every declared track (mirrors `TsUdpSource`'s own
    connect-time PMT wait, bounded the same 10 s). Unlike UDP, the HTTP body
    stream *does* end — `next_samples` returns `Ok(None)` on end-of-stream,
    so `origin::supervisor::supervise` reconnects exactly as for any other
    source's EOF.
  - `source::hls_pull::HlsPullSource` — wraps
    `ll_hls_runtime::client::tokio_client::TokioClient` (the sans-IO LL-HLS
    playback client engine, driven over real HTTP) as a `SourceConnector`/
    `SampleSource`: `connect()` drives the client until its first
    `Output::Init`, recovering the pulled stream's `TrackSpec`s by feeding
    those init bytes through `transmux::Fmp4Demux` once (the *same* demuxer
    the client itself already uses internally — no hand-rolled `moov`
    parse); `next_samples()` relays `Output::Samples` straight through. No
    re-demuxing: the client's own `Fmp4Demux`-based decode is reused
    verbatim.
  - `source::http_auth` — shared auth glue for both HTTP sources: reqwest
    answers Basic/Bearer natively, but not Digest (RFC 7616), so
    `authenticated_get` sends once and, on a `401`, answers the
    `WWW-Authenticate` challenge via the new `broadcast-auth` crate (issue
    #663 P3b) before resending — the same shared `Credentials`/
    `Authenticator` model `rtsp-runtime` already uses. Credentials come from
    the ingest URL's userinfo (mirrors `source::rtsp`'s own handling,
    generalized to any URL).
  - `ll-hls-runtime`'s `client::tokio_client::TokioClient` was itself
    upgraded in lockstep to authenticate via `broadcast-auth` (Basic/Digest/
    Bearer, replacing its previous ad hoc Basic/Bearer-only `Auth` enum) —
    see `ll-hls-runtime`'s own changelog — so `HlsPullSource` gets Digest
    support for free rather than multimux re-implementing the challenge/
    response for the pull path.
  - No new codec/container parsing in multimux: `ts_http` is transport
    (streaming GET) plus `StreamingTsDemux`; `hls_pull` is a thin wrapper
    over `ll-hls-runtime`'s existing client engine + `Fmp4Demux`.

### Security
- **HTTP resource limits on the origin listener** (issue #663 P5, audit-
  concurrency #3): a new `origin::HttpLimits` (`request_timeout`,
  `max_concurrent_requests`, `max_request_body_bytes`) is applied to every
  route via `tower_http::timeout::TimeoutLayer` (default 10 s — comfortably
  above the 5 s LL-HLS blocking-reload cap, so an ordinary long-poll request
  is unaffected), `tower::limit::ConcurrencyLimitLayer` (default 4096), and
  `tower_http::limit::RequestBodyLimitLayer` (default 16 KiB — no legitimate
  request here carries a body). Configurable via the new
  `Config::request_timeout_secs`/`max_concurrent_requests`/
  `max_request_body_bytes`; `Config::validate` rejects a
  `request_timeout_secs` at or below the 5 s blocking-reload cap (it would
  cut off a legitimate blocking reload before the LL-HLS engine's own
  timeout ever gets to resolve or fall back). Defaults preserve every
  existing config/deployment's behaviour.
- **Configurable ingest connect/read timeouts** (issue #663 P5, audit-ingest
  #3): a new `source::IngestTimeouts { connect, read }` (default 10 s
  connect / 30 s read) is now threaded through every ingest source
  (`RtspSource`/`TsUdpSource`/`RtpUdpSource`/`TsHttpSource`/`HlsPullSource`,
  each via a `with_timeouts` builder), bounding both the initial
  connect/handshake step and every subsequent read so a source that never
  responds — or stops responding — surfaces a recoverable error for
  `origin::supervisor::supervise` to reconnect on, rather than hanging a
  route's ingest task forever. Configurable via the new
  `Config::ingest_connect_timeout_secs`/`ingest_read_timeout_secs` (applied
  uniformly to every route); `Config::validate` rejects a non-positive
  value. Defaults preserve every existing config's behaviour.
- **UDP ingest read-timeout** (issue #663 P5.2, audit-ingest #3):
  `source::rtp_udp::RtpUdpSource`/`source::ts_udp::TsUdpSource`'s
  `next_samples()` previously called `UdpSocket::recv` with no timeout — a
  source that stopped sending (dropped multicast feed, wedged encoder) left
  the read pending forever, so `origin::supervisor::supervise` never saw an
  error to reconnect on. Both sessions' per-datagram `recv` is now wrapped in
  `tokio::time::timeout(self.read_timeout, …)`; on expiry `next_samples()`
  returns the same recoverable `MultimuxError::Connect` the supervisor
  already reconnects on for every other read error. `RtpUdpSource` gained
  the `timeouts: IngestTimeouts` field + `with_timeouts` builder it was
  previously missing (mirroring `TsUdpSource`/`RtspSource`); no config or
  behaviour change for a healthy source (default read timeout unchanged at
  30 s).
  - **Deferred (documented, not implemented this pass)**: RTCP Sender
    Report -> wall-clock A/V sync (issue #663 P5.2, audit-ingest #9/#10) —
    `source::rtsp::route_channel` (the interleaved RTCP channel) and
    `source::rtp_udp::RtpUdpSource::connect` (the RTCP companion UDP port)
    each still discard/never bind RTCP; both carry a
    `// TODO(P5.3): RTCP SR wallclock A/V sync` at the exact drop point.
    Judged too large a lift to land safely alongside the bounded-buffer and
    read-timeout hardening above (it would mean redesigning
    `transmux::rtp_stream`'s per-track timing/rebase model, not just this
    crate) — raw per-track RTP-timestamp rebasing is unchanged.

### Changed
- **LL-HLS origin engine moved to `ll-hls-runtime::server`** (issue
  #663/#717 Stage 2 —
  `docs/superpowers/specs/2026-07-18-multimux-hub-design.md`, "ll-hls-runtime
  — client + server in one crate"): `multimux` is now a thin tokio+axum
  adapter over the sans-IO engine, mirroring how it already wraps
  `rtsp-runtime` on the input side. Behaviour-preserving — every existing
  test still passes, same served bytes/URLs/timing:
  - `store::MediaStore`/`store::HealthState` are now re-exports of
    `ll_hls_runtime::server::{MediaStore, HealthState}` (the rolling window,
    the part-404-boundary fix, and health tracking moved there verbatim);
    `crate::store::...` call sites are unaffected.
  - `output::llhls` no longer renders playlists or decides blocking-reload/
    part-availability outcomes itself — it calls
    `MediaStore::resolve_playlist`/`resolve_resource` and drives the actual
    bounded `.await` (the one thing the sans-IO engine can't do): on
    `WouldBlock`, it registers `MediaStore::listen()` before re-resolving (no
    missed-wakeup race), then awaits the listener under its own
    `tokio::time::timeout` (still the 5 s `BLOCKING_RELOAD_TIMEOUT`). The
    reentrant-lock deadlock fix in playlist rendering is preserved (now in
    `ll_hls_runtime::server::media_playlist_m3u8`).
  - New dependency: `ll-hls-runtime` (path + version, `std` feature).

### Added
- **Supervised route lifecycle** (issue #663, P0.2+P0.3+P0.4): each route's
  ingest task is now driven by `origin::supervisor::supervise`, which
  reconnects with capped exponential backoff (`origin::supervisor::Backoff`,
  default 500ms min / 30s max / factor 2.0) on connect failure, pipeline
  error, *or* source end-of-stream, instead of the old one-shot task that
  died for good on the first failure (leaving the HTTP origin serving a
  frozen last playlist as `200 OK` forever). The connect step is abstracted
  behind `origin::supervisor::SourceConnector` (implemented for
  `source::rtsp::RtspSource`) so reconnect is testable without a real RTSP
  server. A route never gives up permanently by default — sources like
  cameras come back.
- **Store health** (`store::MediaStore::{health, set_health}` /
  `store::HealthState`): each route's `MediaStore` now tracks
  `Connecting`/`Live`/`Reconnecting`/`Failed`, set by the supervisor at each
  connect/pipeline transition; a state change bumps the store's existing
  progress watch so a blocked reader (e.g. an LL-HLS long-poll reload) wakes
  on a health transition too, not just new media.
- **Graceful shutdown**: `origin::serve` now installs a shutdown signal
  (Ctrl-C, plus `SIGTERM` on unix) that both drains in-flight HTTP requests
  via `axum::serve(..).with_graceful_shutdown(..)` and breaks every route's
  supervise loop; `serve` joins each supervisor task (aborting one that
  doesn't return within a short grace period) before returning `Ok(())`,
  rather than leaving ingest tasks running detached past shutdown.
- **Structured errors + secret redaction + tracing** (issue #663, P1a):
  - `error::MultimuxError` replaces the stringly `Config(String)`/
    `Source(String)` variants with field-carrying `thiserror` variants
    (`ConfigRead`/`ConfigParse`/`ConfigInvalid`/`Connect`/`Protocol`/`Sdp`/
    `Auth`/`Depay`, plus the existing `Transmux`/`Io`), so callers can match
    on failure *kind* instead of parsing a string, following the
    `rtsp-runtime` convention.
  - **Secret redaction**: an RTSP source URL's `user:pass@` userinfo can no
    longer leak into an error message, `Debug` output, or a log line.
    `config::Route` and `source::rtsp::RtspSource` now have manual `Debug`
    impls that redact `rtsp_url`/`url` to `***@host`; every connect-time
    error path (bad URL parse, connect/TLS/SNI failure, userinfo-stripping
    failure) redacts or uses the already userinfo-stripped URL rather than
    the raw credentialed one.
  - `tracing` throughout: `origin::supervisor::supervise` is
    `#[tracing::instrument]`ed per route (connect/live `info!`, disconnect/
    reconnect `warn!` with backoff delay + attempt count, health
    transitions logged) and `origin::serve` logs startup/shutdown/aborted
    supervisor tasks, replacing the ingest supervisor's `eprintln!`s. The
    library only emits events — `multimux-cli` owns subscriber init
    (`tracing-subscriber` `fmt` + `EnvFilter`, `RUST_LOG`-overridable,
    default `info`); the CLI's own top-level fatal-error report stays a
    plain `eprintln!` so it's never swallowed by a log filter.
- **Prometheus metrics + health/readiness endpoints** (issue #663, P1c):
  - New `prometheus` module: installs a single process-wide
    `metrics-exporter-prometheus` recorder (idempotent — safe to call from
    every `AppState::new`, including many tests sharing one process) and
    renders its snapshot for `GET /metrics`.
  - Metrics recorded throughout the crate via the `metrics` facade:
    `multimux_route_up` (gauge, `route`; mirrors `HealthState` — 1.0 while
    `Live`), `multimux_source_reconnects_total` (counter, `route`; bumped by
    `origin::supervisor::supervise` on each `Reconnecting` transition),
    `multimux_segments_produced_total`/`multimux_parts_produced_total`
    (counters, `route`; bumped in `pipeline::run_pipeline`, which now takes a
    `route: &str` parameter for this label),
    `multimux_active_blocking_requests` (gauge; inc/dec around
    `output::llhls`'s blocking `wait_for_progress`/`wait_for_part` waits via
    an RAII guard), and `multimux_http_requests_total`/
    `multimux_http_request_duration_seconds`/`multimux_bytes_served_total`
    (labels `route`, `path`, and `status` for the requests counter; recorded
    by a new `origin::router` global middleware layer for every request,
    root endpoints included). Cardinality is bounded on purpose: `route` is a
    configured stream name or `"unknown"`, `path` is one of a small fixed set
    of kinds (`playlist`/`segment`/`part`/`init`/`metrics`/`health`/`other`).
  - `GET /healthz` (liveness, always 200) and `GET /readyz` (readiness: 200
    once at least one configured route is `Live`, 503 otherwise) mounted at
    the origin root alongside `/metrics`, above the per-stream `/{stream}/`
    nests.
  - `origin::AppState` gained a `metrics_handle` field and an `AppState::new`
    constructor (replacing the old bare struct literal at every call site).

### Fixed
- **LL-HLS spec-conformance** (issue #663, P2 — RFC 8216bis):
  - `#EXT-X-TARGETDURATION` is now `round(max(configured target, max actual
    segment duration ever seen))`, not `ceil(configured target)`. The
    segmenter cuts on the next keyframe *after* the configured target, so a
    real segment routinely runs longer than the configured value — advertising
    the configured target alone under-declared TARGETDURATION and violated RFC
    8216bis §4.4.3.1 (a MUST: every Media Segment's rounded EXTINF ≤
    TARGETDURATION). `store::MediaStore` now tracks a lifetime
    `max_segment_duration` (never reset on window eviction) that the LL-HLS
    renderer folds into the tag.
  - Blocking-reload `_HLS_msn` semantics (§6.2.5.2): a bare `_HLS_msn` (no
    `_HLS_part`) now waits until segment `msn` is a fully-present **closed**
    Media Segment, rather than resolving as soon as the segment merely *opens*
    with one live part (the old `unwrap_or(0)` conflated it with
    `_HLS_part=0`). `_HLS_msn`+`_HLS_part` keeps the existing part-count
    semantics.
  - `_HLS_msn`/`_HLS_part` abuse bounds (§6.2.5.2): `_HLS_part` without
    `_HLS_msn`, or an `_HLS_msn` more than a small bound beyond the current
    live edge, is now rejected promptly with `400 Bad Request` instead of
    always blocking to the 5 s timeout and returning `200`.
  - `Cache-Control` + permissive CORS on every origin response: immutable
    `max-age=31536000, immutable` on init/segment/part byte ranges, `no-cache`
    on playlists, and `Access-Control-Allow-Origin: *` (+ methods/headers, with
    an `OPTIONS` preflight handler) on everything — browser LL-HLS players
    (hls.js) are commonly on a different origin than the API.
  - **`GET /media.m3u8` deadlocked on every request** (found by
    `ll-hls-client`'s issue #717 slice 5 acceptance test, the first thing in
    the workspace to ever drive this endpoint over a real HTTP round trip
    rather than calling `output::llhls::media_playlist_m3u8` directly):
    `media_playlist_m3u8` called `store::MediaStore::with_segments_and_parts`
    (which locks `MediaStore`'s internal `std::sync::Mutex`) and, from
    *inside* that closure, called `store.max_segment_duration()` — which
    locks the same, non-reentrant mutex again. Every request to `/media.m3u8`
    (blocking or not, empty store or not) self-deadlocked the handling task
    forever. `target_duration_secs()`/`max_segment_duration()` are now read
    *before* taking the `with_segments_and_parts` lock.

## [0.2.2] - 2026-07-18

### Fixed
- **LL-HLS preload-hint parts no longer 404 at every segment boundary.** The
  segmenter emits a segment's *final* part and closes the segment in the same
  step; `add_segment` then evicted that segment's parts from `live_parts`
  immediately — so the final part (exactly the one the `#EXT-X-PRELOAD-HINT`
  points at) existed for only microseconds, and the in-flight blocking part
  request that raced the close still 404'd. 0.2.1 made not-yet-produced parts
  *block*; this makes the just-produced final part *survive*: `add_segment` now
  moves a closed segment's parts into a bounded `recent_parts` buffer that
  `part_bytes` also checks, so the hinted final part is served (HTTP 200) after
  its segment closes instead of 404ing. Eliminates the per-segment 404 spam +
  the boundary latency bump. Bounded oldest-first like `live_parts`; closed
  parts are still not rendered in the playlist (the whole segment is).

### Fixed
- **LL-HLS preload-hint parts no longer 404.** A request for a Partial Segment
  the media playlist promised via `#EXT-X-PRELOAD-HINT` but that the origin had
  not produced yet returned `404` immediately, instead of holding the request
  open until the part became available (RFC 8216bis §6.2.2 / §6.3.1 blocking
  Partial-Segment delivery). Every low-latency client (hls.js, Safari native)
  therefore hammered the hinted part with 404s until it happened to exist,
  spamming errors and forcing a fall back to full-segment loads — defeating the
  low-latency path. The part byte handler now blocks (reusing the same progress
  watch as the blocking playlist reload) until the part is produced, or returns
  `404` *promptly* once its segment closes without it (a real segment boundary)
  or the blocking timeout elapses. Observed against a live on-camera stream.

### Breaking
- The bundled `multimux` **binary** (the RTSP→LL-HLS CLI) moved to a new
  dedicated crate, **`multimux-cli`**. `multimux` is now a **library only**
  (its `serve`/`config`/`origin`/`pipeline`/`source`/`store` API is unchanged).
  `cargo install multimux-cli` provides the `multimux` binary as before. The
  `cli` cargo feature (and the `clap` dependency) were removed from `multimux`.

## [0.1.0] - 2026-07-15
### Added
First release (issue #663): a live RTSP → LL-HLS just-in-time repackaging HTTP
origin — a thin client + server wrap around `rtsp-runtime` (RTSP pull) and
`transmux` (RTP depayload + LL-HLS CMAF segmentation).

- **Config** (`config::Config`/`Route`): CLI-first, with an optional JSON
  config file for multiple routes; `bind`, `target_duration_secs`,
  `part_target_ms`, `window_segments`, and `routes: [{ name, rtsp_url }]`;
  `Config::validate()` rejects empty route sets, duplicate stream names, and
  nonsensical timing/window values.
- **RTSP ingest** (`source::rtsp::RtspSource`/`RtspSession`): DESCRIBE → SETUP
  (interleaved TCP, one media per SETUP) → PLAY over
  `rtsp_runtime::io::AsyncRtspClient`; SDP → per-track `CodecConfig` via
  `transmux`'s SDP-fmtp helpers; interleaved RTP routed per channel into
  `transmux::RtpStreamDepacketizer`, yielding timed `Sample`s.
- **Per-route pipeline** (`pipeline::run_pipeline`): drives a `SampleSource`
  (real `RtspSession` or, for tests/examples, `MockSource`) through a
  `transmux::ll_hls::LlHlsSegmenter`, publishing every init segment, ready
  part, and ready segment into a `StreamStore`; flushes the buffered tail at
  end-of-stream.
- **`StreamStore`** (`store::StreamStore`): per-stream in-RAM rolling window
  (init segment + a bounded `VecDeque` of closed segments + the in-progress
  segment's live parts), oldest segment evicted on roll; a
  `tokio::sync::watch` bumped on every new part/segment drives blocking
  playlist reload; renders the LL-HLS media playlist per RFC 8216bis
  (`#EXT-X-PART-INF`/`#EXT-X-SERVER-CONTROL`/`#EXT-X-PART`/
  `#EXT-X-PRELOAD-HINT`), never advertising an `#EXTINF`/URI for an
  unclosed segment (§4.4.4.9).
- **HTTP origin** (`origin::{router, serve}`, axum): `master.m3u8`,
  `media.m3u8` (blocking reload on `_HLS_msn`/`_HLS_part`, RFC 8216bis
  §6.2.5.2, bounded so a stalled source can't hang a request forever), and a
  catch-all serving the dynamic `init-*.mp4`/`seg-*.m4s`/`part-*.m4s`
  filenames the playlist emits. `origin::serve(config)` wires one
  `StreamStore` + one spawned RTSP pipeline task per configured route, then
  binds and serves — a single bad/unreachable source logs to stderr and ends
  only that route's task, never the server.
- **CLI binary** (`multimux`, `cli` feature, on by default): `--config <FILE>`
  or the single-route quick start `--rtsp <URL> --name <NAME>`, plus
  `--bind`/`--target-duration`/`--part-ms`/`--window`, per
  `docs/CLI-STANDARD.md`.
- **Examples**: `serve_mock` (synthetic stream, no RTSP/network needed) and
  `serve_rtsp` (serves one real RTSP URL given on the command line).

### v1 scope
LL-HLS only (DASH/LL-DASH is v1.1); RTSP pull only (no SRT/TS/file ingest); no
per-viewer sessions/SSAI/manifest rewrites; no DVR/VOD/disk spill (RAM-only
rolling window); no TLS/auth (front it with a reverse proxy); no trick-play.