Skip to content

Bump apple-actions/import-codesign-certs from 3 to 7 - #2

Closed
dependabot[bot] wants to merge 2 commits into
mainfrom
dependabot/github_actions/apple-actions/import-codesign-certs-7
Closed

Bump apple-actions/import-codesign-certs from 3 to 7#2
dependabot[bot] wants to merge 2 commits into
mainfrom
dependabot/github_actions/apple-actions/import-codesign-certs-7

Conversation

@dependabot

@dependabot dependabot Bot commented on behalf of github Jun 4, 2026

Copy link
Copy Markdown
Contributor

Bumps apple-actions/import-codesign-certs from 3 to 7.

Release notes

Sourced from apple-actions/import-codesign-certs's releases.

v7.0.0

What's Changed

Full Changelog: Apple-Actions/import-codesign-certs@v6.1.0...v7.0.0

v6.1.0

What's Changed

Full Changelog: Apple-Actions/import-codesign-certs@v6.0.0...v6.1.0

v6.0.0

What's Changed

Dependency upgrades

... (truncated)

Commits
  • 5142e02 [main] Switch from ncc to esbuild and upgrade dependencies
  • d118e79 [main] Lock down versions with SHA
  • fa7fd27 Merge pull request #173 from Apple-Actions/dependabot/npm_and_yarn/knip-6.2.0
  • 44741a8 Bump knip from 5.78.0 to 6.2.0
  • 71c8098 Merge pull request #168 from Apple-Actions/dependabot/npm_and_yarn/picomatch-...
  • 7ebdf79 Bump picomatch from 2.3.1 to 2.3.2
  • 4cf892c Merge pull request #167 from Apple-Actions/dependabot/github_actions/actions/...
  • 0d67bcd Bump actions/setup-node from 6.2.0 to 6.3.0
  • 50a47bf Merge pull request #166 from Apple-Actions/dependabot/npm_and_yarn/flatted-3.4.2
  • 2219450 Bump flatted from 3.4.1 to 3.4.2
  • Additional commits viewable in compare view

Dependabot compatibility score

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting @dependabot rebase.


Dependabot commands and options

You can trigger Dependabot actions by commenting on this PR:

  • @dependabot rebase will rebase this PR
  • @dependabot recreate will recreate this PR, overwriting any edits that have been made to it
  • @dependabot show <dependency name> ignore conditions will show all of the ignore conditions of the specified dependency
  • @dependabot ignore this major version will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
  • @dependabot ignore this minor version will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
  • @dependabot ignore this dependency will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)

troykelly and others added 2 commits June 4, 2026 11:01
Multiview — an efficient, hardware-accelerated Rust live video multiview/mosaic
generator. It ingests many live sources (RTSP/HLS/SRT/RTMP/NDI/file/synthetic),
composites them into a templated grid on CPU or GPU, and writes HLS/file output;
the encode-once-mux-many design fans one encode to many transports.

Highlights:
- Fixed-cadence output clock — one valid frame per tick, never stalls; inputs are
  sampled, never pacing (the cardinal continuous-output invariant).
- Custom CPU/GPU compositor with a fixed linear-light colour pipeline (NV12
  throughout); per-tile overlays, captions (HLS WebVTT + DVB-sub), analog/wall
  clocks, audio meters, and fault badges.
- 16-crate Rust workspace under strict typing + TDD + adversarial-review
  guardrails; LGPL-clean default build (GPL codecs + NDI opt-in).
- Multi-arch Docker images, docker compose examples, and SemVer release automation.

Status: early stage — the engine ingests, composites, encodes, and writes HLS/file
output today; the web UI, control API, and live RTSP/NDI/RTMP output servers are
built as libraries and on the near-term roadmap (see ROADMAP.md). Dual-licensed
MIT OR Apache-2.0.

Developed iteratively with AI assistance; the full internal development history is
retained privately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bumps [apple-actions/import-codesign-certs](https://github.com/apple-actions/import-codesign-certs) from 3 to 7.
- [Release notes](https://github.com/apple-actions/import-codesign-certs/releases)
- [Commits](Apple-Actions/import-codesign-certs@v3...v7)

---
updated-dependencies:
- dependency-name: apple-actions/import-codesign-certs
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
@dependabot dependabot Bot added dependencies Pull requests that update a dependency file github_actions Pull requests that update GitHub Actions code labels Jun 4, 2026
@troykelly troykelly closed this Jun 4, 2026
@dependabot @github

dependabot Bot commented on behalf of github Jun 4, 2026

Copy link
Copy Markdown
Contributor Author

OK, I won't notify you again about this release, but will get in touch when a new version is available. If you'd rather skip all updates until the next major or minor version, let me know by commenting @dependabot ignore this major version or @dependabot ignore this minor version. You can also ignore all major, minor, or patch releases for a dependency by adding an ignore condition with the desired update_types to your config file.

If you change your mind, just re-open this PR and I'll resolve any conflicts on it.

@dependabot
dependabot Bot deleted the dependabot/github_actions/apple-actions/import-codesign-certs-7 branch June 4, 2026 11:17
troykelly pushed a commit that referenced this pull request Jun 7, 2026
…-L1 §2/§3)

Complete the safe NDI handle set on the NdiV6 resolver:

- NdiReceiver (recv.rs): non-blocking video sampling (inv #1/#2/#10 —
  sampled, never pacing). capture_video returns the latest frame or None
  on timeout/non-video; audio + metadata are null sinks so only video is
  allocated. RecvVideoFrame owns the SDK buffer and frees it
  exactly-once on Drop, borrowing the receiver (&'r) so the free target
  is alive by construction (no dangling free is expressible). UYVY_BGRA
  color format, highest bandwidth.
- NdiFinder (find.rs): source discovery. current_sources copies each
  name into an owned NdiSourceName before returning — callers never hold
  a pointer into the finder's transient array.
- Runtime init (table.rs): NdiV6::ensure_initialized() calls
  NDIlib_initialize (idempotent); every handle calls it on construct.
  Sending works without it, but discovery (advertise/browse) needs it.

All unsafe stays in this crate; consumers stay forbid(unsafe_code).

Hardware-validated on the SDK-equipped x86_64 box
(tests/live_loopback.rs): one process sends a UYVY luma gradient,
discovers its own source via the finder, connects a receiver, and
captures the frame back — 64x64 UYVY, mean luma round-trips at 127.0
(the sent gradient mean). NDI carries video over SpeedHQ (visually
lossless, not bit-exact), so the assertion is structural (geometry +
mean-luma band), matching the GPU/codec SSIM/PSNR testing tier. (The
headless box has no mDNS, so discovery is backed by the SDK's
ndi-discovery-server via ndi-config.v1.json — a host-side test harness
concern, nothing in the repo.)

clippy -D warnings green default AND --features bindings; fmt clean;
default build stays LGPL-clean (recv/find gated on `bindings`).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
troykelly pushed a commit that referenced this pull request Jun 7, 2026
Wire the production NdiReceiver: `SdkNdiReceiver` delegates the receive
seam (receive → sample) to the safe NdiReceiver from multiview-ndi-sys.
multiview-input stays forbid(unsafe_code). NdiProducer is unchanged
(generic over dyn NdiReceiver), so swapping FakeNdiReceiver for
SdkNdiReceiver is the only difference between a unit test and live
ingest.

- Sampled, never pacing (inv #1/#2/#10): each receive() is a bounded
  non-blocking capture on the ingest thread; no frame this instant →
  ReceivedFrame::None (last-good held, tile rides its state machine).
- Copies pixels out of SDK-owned memory before the RecvVideoFrame drops
  (free-exactly-once); negative NDI timecode → genpts fallback; a
  malformed geometry is a typed skip, never a panic.
- Drop order: receiver before capability (the receiver holds fn pointers
  into the capability's still-mapped Library).
- New opt-in feature `ndi-bindings` = ndi + multiview-ndi-sys/bindings
  (build-time bindgen over the licensed header); plain `--features ndi`
  stays SDK-free and CI-buildable. Default build stays LGPL-clean.
- ndi-sys: NdiRuntime is now Send (the SDK table is process-global +
  immutable, the Library is Send) so a live receiver can move onto the
  ingest thread; deliberately not Sync (owned, never shared by ref).

Hardware-validated on the SDK-equipped x86_64 box (tests/ndi_live.rs,
live_ingest): a sys NdiSender publishes a UYVY gradient, the finder
discovers it, and the production NdiProducer over SdkNdiReceiver yields
a 64x64 NV12 ProducedFrame (pixels=6144 = w*h*3/2) — proving the whole
ingest path: recv_capture → recv_free → ReceivedVideoFrame → UYVY→NV12
→ ProducedFrame. The CI-safe probe test is retained.

clippy -D warnings green: default, --features ndi (SDK-free), and
--features ndi-bindings (on box); fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
troykelly pushed a commit that referenced this pull request Jun 8, 2026
…ll + scale-at-composite, ADR-0034)

Implements RT-6 from the decoupled-routing backlog (ADR-0034): instant, LIVE,
sub-frame-seamless VIDEO->cell switching within the existing single-program path,
carrying the two heaviest verdict-mandated fixes.

FIX #1 — O(1) rebind_cell (multiview-engine):
- CompositorDrive gains a cell-id -> index map (with_cell_ids/set_cell_ids) and
  rebind_cell(cell_id, source_id): a pure source re-point that mutates the bound
  source in place and SKIPS solve_layout/validate entirely (geometry unchanged).
  Verifies the target store exists; an unknown cell/source is an honest
  Error::Rebind and the prior binding is held (no panic). Applied at the existing
  frame-boundary control hook; never blocks the clock (inv #1).

FIX #2 — scale-at-composite (multiview-compositor):
- Tile gains dst_w/dst_h + placed()/scaled() constructors; the CPU reference
  resamples each source's NV12 planes into its destination cell rect
  (nearest-neighbour, stays NV12 — inv #5/#6). The GPU path now scales too
  (TileParams.dst_size + composite.wgsl), so a cross-geometry swap renders
  correctly on both backends (1:1 reduces to the prior placement, byte-identical).
- The drive computes each cell's pixel rect and passes it as the tile destination.

FIX #3 — coalesce + bound (multiview-cli):
- command_drain becomes CommandDrain: SwapSource / salvo source-recalls route
  through the O(1) rebind_cell (no re-solve), coalesced and capped at
  MAX_REPOINTS_PER_TICK per frame-boundary pass, with a bounded drop-oldest
  backlog so a salvo storm cannot blow the tick budget.

Tests (RED-first): cross-geometry swap renders correct (was broken under 1:1);
K-command salvo does <=1 layout re-solve; per-tick re-point storm is capped;
rebind to unknown cell/source is a clean error/hold; a no-op run composes
byte-identically. GPU cross-geometry parity asserted via SSIM. Protected
output-clock tests (runtime.rs/drive.rs) unchanged and green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
troykelly added a commit that referenced this pull request Jun 8, 2026
… startup hold)

The real pipeline started the output clock the instant the ingest threads
were spawned, so the first output tick (and several after it) sampled the
cold NO_SIGNAL/last-good slate while the per-source decode threads were
still opening + decoding + scaling their first frame — a startup transient
where the program output held the placeholder (tick 0 luma == 16, the NV12
slate) before real content appeared (#40, the residual after the #39
compositor real-time fix).

Add a BOUNDED prime-wait between spawning the ingest producers and seeding
the runtime: wait until every cell-bound source's TileStore has published
one frame, OR PRIME_WAIT_BUDGET (1.5 s) elapses. CRITICAL inv #1/#2: a
source that never produces (the deliberately-missing source, a dead/wedged
live input) can NOT block startup — once the budget elapses the clock
starts anyway and that tile rides its NO_SIGNAL/last-good placeholder
(already produced by read_at). The runtime is built AFTER the wait so tick
0 anchors to the post-prime instant (no catch-up burst). Cadence and
per-tick logic are unchanged; only the first tick is delayed, and no input
ever paces the output.

The wait's clock + sleep are injected so it is deterministically testable
with no real sleeping. Add a tiny read-only TileStore::is_primed() accessor
(lock-free atomic check) for the prime test.

TDD:
- framestore: is_primed_flips_false_to_true_on_first_publish
- cli prime_wait_tests: proceeds_immediately_when_all_stores_are_primed,
  proceeds_after_timeout_when_a_store_never_primes (proves no hang on a dead
  source), no_bound_stores_does_not_delay_the_clock

Content-aware verify (overlays off, net_sport.ts -> 640x360@25, 250 ticks):
first-25 slate frames 1 -> 0; frame-1 luma 16 (slate) -> 96.8 (real
content). Steady-state motion 85% unchanged; render stays real-time
(~10.46 s wall for 10 s content).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
troykelly added a commit that referenced this pull request Jun 8, 2026
Adds a pure, always-compiled FrameAssembler above the -20 depacketizer:
it walks the stream of depacketized packets (one PacketUnit each) and
reassembles their SRD segments into a single line-addressed raster,
closing a frame on the RFC 4175 marker bit or, when that marker is lost,
on the next RTP timestamp change.

Honest partial frames, never a stall (invariants #1/#2): reorder within a
frame is absorbed by the line-addressed write; an RTP sequence gap surfaces
as AssembledFrame::discontinuity; a lost marker is recovered by the
timestamp-change flush (complete=false); end-of-stream drops any
in-progress frame via finish() rather than awaiting a marker that will not
come. Memory is bounded to two raster buffers (RasterGeometry caps the
allocation at MAX_RASTER_BYTES); every byte placement is checked, so an
out-of-range line/offset is dropped, never written out of bounds.

The 90 kHz RTP timestamp is surfaced verbatim as raw_pts (a
producer-timebase tick); the float-free 90 kHz->ns rebase stays in the
downstream PtsNormalizer (WrapBits::Rtp32), not here. Wiring the assembler
into a FrameProducer + PTP timing and the real NIC RX is IN-2 (gated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
troykelly added a commit that referenced this pull request Jun 8, 2026
…etize seam

Failing test for the gated `webrtc` ingest core: the connection-state
lifecycle (Created->Connecting->Connected->Closed/Failed) with validated
transitions, the H.264 RTP depacketize->access-unit seam (keyframe-gated,
FU-A reassembly), and a `WebRtcProducer: FrameProducer` driven by an
INJECTED fake `MediaEngine` into the `IngestPump`->`TileStore`. No real
network/ICE/DTLS/SRTP — everything is injected. Re-asserts inv #1/#2: a
pre-keyframe/garbage source is sampled and dropped, never stalling.

Compiles red: the `webrtc::transport` symbols (SessionState, MediaEngine,
RtpFrame, H264Depacketizer, WebRtcProducer) do not exist yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
troykelly added a commit that referenced this pull request Jun 8, 2026
…cketize seam)

Turn the compile-only WebRTC transport shell into the testable ingest core,
all behind the off-by-default `webrtc` feature so the default build stays
pure-Rust / native-dep-free / LGPL-clean (no native WebRTC library linked;
the ICE/DTLS/SRTP engine is the application layer's job).

Adds, in `webrtc::transport`:
- `SessionState` (Created->Connecting->Connected->Closed/Failed) with a
  validated `advance`/`can_advance_to` — an illegal jump or a transition out
  of a terminal state is an error, never a panic. `WebRtcSession` now carries
  the lifecycle (`connect`/`fail`/`close`/`state`).
- `MediaEngine` trait: the application-layer seam a concrete ICE/DTLS/SRTP
  engine (e.g. a sans-IO str0m driver wired at the binary) plugs into,
  yielding decrypted `RtpFrame`s. Defined here; never linked here.
- `H264Depacketizer` (RFC 6184): a pure state machine over injected packets
  reassembling single-NAL / STAP-A / FU-A into keyframe-gated
  `DepacketizedFrame`s. Delta frames before the first IDR are dropped;
  FU-A reassembly is bounded (MAX_ACCESS_UNIT_BYTES) and drops, never grows;
  sequence gaps surface as a discontinuity that re-anchors the normalizer.
- `WebRtcProducer: source::FrameProducer` bridging engine -> depacketizer ->
  pump, reporting `WrapBits::Rtp32` + a 90 kHz timebase so the RTP timestamp
  rebases correctly. Non-blocking pulls only.

Re-asserts inv #1/#2: a dead/lagging engine yields None and is held (never
paces the clock); a pre-keyframe/garbage source is sampled and dropped, never
stalling the pump. No new dependency; crate stays `unsafe_code = forbid`.

The real socket/ICE/DTLS/SRTP path needs a peer + the application-layer
engine, so the live network test is `#[ignore]`d and skips honestly without
`MULTIVIEW_WEBRTC_PEER`. The pure lifecycle + depacketize + producer->store
seam is fully unit-tested with injected fakes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
troykelly added a commit that referenced this pull request Jun 8, 2026
SUR-3 phase 2/3: drive the linked libav text-subtitle decoders (subrip,
webvtt, mov_text) directly from a caption packet and assert the unified
CaptionCue::Text shape — markup-stripped lines plus a start rebased onto the
ns timeline through the configured time-base (invariant #3). Also re-asserts
that an empty/malformed caption packet yields no cue and never panics/stalls
(intermittent, best-effort; invariants #2/#10).

Fails to compile: references CaptionDecoder::decode_bytes_for_window, the
in-container text path that sets an explicit packet window, not yet added.
Fixtures only (no network/mux), gated behind the ffmpeg feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
troykelly added a commit that referenced this pull request Jun 8, 2026
SUR-3 phase 2/3 (multiview-ffmpeg only): prove and broaden native TEXT
caption decode beyond HLS WebVTT through the already-linked libav subtitle
decoders. The CaptionDecoder wiring for subrip/webvtt/mov_text/ass/cc_dec
existed but had no test exercising the real text decode path; this lands a
fixture-only integration test that drives subrip, webvtt and mov_text packets
to the unified CaptionCue::Text shape and asserts:

- markup-stripped display lines (webvtt inline <b>..</b> stripped, subrip/
  mov_text bodies recovered);
- the cue start rebased onto the ns timeline through the configured stream
  time-base, verified non-tautologically against a 90 kHz time-base
  (90_000 ticks -> 1e9 ns), invariant #3;
- an empty caption packet yields no cue (no error, no panic), and an
  invalid-UTF-8 packet degrades to a typed Result error without panicking or
  wedging the decoder (a later valid packet still decodes) — invariants #2/#10.

Adds CaptionDecoder::decode_bytes_for_window, the in-container text path that
stamps the packet's on-screen duration alongside pts/dts (decode_bytes left
unchanged for the embedded-CC convenience). No new dependency: the decoders
are already in the LGPL FFmpeg 7.1 build; behind the existing
feature. The default pure-Rust build is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
troykelly pushed a commit that referenced this pull request Jun 8, 2026
…REEN)

Make the already-wired CaptionSource::EmbeddedCc path actually recover
embedded closed captions, completing the SUR-3 remainder (CEA-608/708 +
ASS text path) in multiview-ffmpeg only (no cli/input wiring).

* caption_decode: the EmbeddedCc path now opens cc_dec with `real_time=1`.
  cc_dec has the libav `delay` capability — by default it buffers a whole
  caption block and emits only on a flush (null) packet, so our streaming
  decode-one-packet contract would emit nothing until end-of-stream.
  `real_time=1` makes it emit each cue as the closing control code (608
  End-Of-Caption) is decoded — the live, sampled delivery the cue store
  expects (captions.md §4/§5). This is the load-bearing fix the RED tests
  caught.
* extract_a53_cc(&Video) -> Option<Vec<u8>>: pull the AV_FRAME_DATA_A53_CC
  side data (raw cc_data triplets) off a decoded video frame via the safe
  ffmpeg_next accessor — the missing extraction piece. Must run on the raw
  decoded frame, before NV12 conversion (libswscale drops side data).
* CaptionDecoder::decode_video_frame(&Video, pts): the embedded-CC path
  end-to-end on one frame (extract -> cc_dec); a frame with no A53 side
  data yields no cue, never an error (invariants #2, #10).
* test_fixtures::generate_a53_cc_ts: an mpeg2video MPEG-TS whose input
  frames each carry one EIA-608 word as A53 side data, encoded with
  a53cc=1 — the FFmpeg CLI has no source that attaches known A53 captions,
  so (like the dvbsub fixture) it is built directly through libav. The
  decoded frames reproduce the same A53 triplets a cc_dec recovers.

LGPL-clean throughout: mpeg2video + the linked cc_dec/ass decoders, no
x264/x265. No new dependency. The crate's existing `#![allow(unsafe_code)]`
(deny + // SAFETY:) covers the one new fixture `unsafe fn` block.

GREEN evidence:
  cargo fmt --all -- --check                                          PASS
  cargo clippy -p multiview-ffmpeg --all-targets -- -D warnings       PASS
  cargo clippy -p multiview-ffmpeg --all-targets --features ffmpeg
    -- -D warnings                                                    PASS
  cargo clippy -p multiview-ffmpeg --all-targets --features
    test-fixtures -- -D warnings                                      PASS
  cargo test -p multiview-ffmpeg --features ffmpeg          (caption: 4+6) PASS
  cargo test -p multiview-ffmpeg --features test-fixtures (lib 54 + a53 2) PASS
  cargo check --workspace                                             PASS

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
troykelly pushed a commit that referenced this pull request Jun 8, 2026
…i planning

Implements IN-3 NDI ingest behind the off-by-default, license-isolating `ndi`
feature (runtime-loaded via the OUT-3 `multiview-ndi-sys` seam; the proprietary
SDK is never vendored or linked at build time).

multiview-input `ndi` module (gated; default build untouched, LGPL-clean):
- convert.rs: pure, panic-free, checked-indexing UYVY->NV12 (a repack: luma
  verbatim, 4:2:2->4:2:0 by averaging the two stacked chroma rows) and
  BGRA->NV12 (BT.709 limited-range R'G'B'->Y'CbCr in fixed point, 4:2:0 box
  filter). `ReceivedVideoFrame::new` validates geometry/stride/buffer as typed
  refusals. The result is tagged BT.709 limited; the compositor runs the full
  inv-#8 pipeline per tile (no compositor dep here — correct dep direction).
- receiver.rs: the `NdiReceiver` sample seam + `FakeNdiReceiver` (injected, no
  SDK) + `NdiRecvFourCc`/`ReceivedFrame` (None == the recv-timeout "no frame
  this tick").
- producer.rs: `NdiProducer: FrameProducer` — samples the receiver (non-blocking),
  converts to an NV12 `ProducedFrame`; a quiet sample is Ok(None), a fault is an
  Err the supervisor reconnects from. NDI 100 ns timecode -> timebase 1/1e7,
  WrapBits::None (continuous monotonic). Sampled, never pacing (inv #1/#2/#10).
- license.rs (audited accept-gate) + loader.rs (runtime probe over
  multiview-ndi-sys, stays forbid(unsafe_code)) + NDI(R) attribution.
- error.rs: `NdiConvert`/`NdiRecv` `#[from]` arms (gated).
- ndi_live.rs: probe test (runs in CI, runtime-absent tolerated) + an
  `#[ignore]`d live-runtime test (needs the SDK + a live NDI sender).

multiview-cli: new off-by-default `ndi` feature (forwards multiview-input/ndi;
does NOT imply ffmpeg — NDI bypasses libav); `full` uses it. `ingest_plan_for`
now maps `SourceKind::Ndi` to a live `SourceLocation::Ndi { name }` (feature on)
instead of the hard "not wired" error, keeping an honest typed refusal when the
feature is off. `ingest_loop` routes NDI to `drive_ndi`: a supervised-reconnect
host-memory receive that publishes NV12 into the last-good store and degrades
the tile (LIVE->...->NO_SIGNAL) when the runtime is absent/unlicensed — it
returns promptly so the prime-wait/output clock is never extended (inv #1).

HONEST SCOPE (partial): binding a live SDK-backed receiver onto the resolved
`multiview-ndi-sys` function table is a live-only concern (needs the proprietary
ABI + a real NDI network, neither in CI) and is the deferred half —
`connect_ndi_receiver` probes the runtime and returns its typed status without a
live receiver yet, so an NDI tile currently degrades rather than streaming. The
pure receive->NV12 conversion + the NdiProducer drive shape that consume a
receiver are complete and fully unit-tested over the injected fake.

The proprietary obligation (multiview-ndi-sys + libloading) is absent from the
default build (verified via cargo tree); the default `cargo deny check
--all-features=false` graph is unchanged (only an optional dep edge added).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
troykelly pushed a commit that referenced this pull request Jun 8, 2026
…-L1 §2/§3)

Complete the safe NDI handle set on the NdiV6 resolver:

- NdiReceiver (recv.rs): non-blocking video sampling (inv #1/#2/#10 —
  sampled, never pacing). capture_video returns the latest frame or None
  on timeout/non-video; audio + metadata are null sinks so only video is
  allocated. RecvVideoFrame owns the SDK buffer and frees it
  exactly-once on Drop, borrowing the receiver (&'r) so the free target
  is alive by construction (no dangling free is expressible). UYVY_BGRA
  color format, highest bandwidth.
- NdiFinder (find.rs): source discovery. current_sources copies each
  name into an owned NdiSourceName before returning — callers never hold
  a pointer into the finder's transient array.
- Runtime init (table.rs): NdiV6::ensure_initialized() calls
  NDIlib_initialize (idempotent); every handle calls it on construct.
  Sending works without it, but discovery (advertise/browse) needs it.

All unsafe stays in this crate; consumers stay forbid(unsafe_code).

Hardware-validated on the SDK-equipped x86_64 box
(tests/live_loopback.rs): one process sends a UYVY luma gradient,
discovers its own source via the finder, connects a receiver, and
captures the frame back — 64x64 UYVY, mean luma round-trips at 127.0
(the sent gradient mean). NDI carries video over SpeedHQ (visually
lossless, not bit-exact), so the assertion is structural (geometry +
mean-luma band), matching the GPU/codec SSIM/PSNR testing tier. (The
headless box has no mDNS, so discovery is backed by the SDK's
ndi-discovery-server via ndi-config.v1.json — a host-side test harness
concern, nothing in the repo.)

clippy -D warnings green default AND --features bindings; fmt clean;
default build stays LGPL-clean (recv/find gated on `bindings`).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
troykelly pushed a commit that referenced this pull request Jun 8, 2026
Wire the production NdiReceiver: `SdkNdiReceiver` delegates the receive
seam (receive → sample) to the safe NdiReceiver from multiview-ndi-sys.
multiview-input stays forbid(unsafe_code). NdiProducer is unchanged
(generic over dyn NdiReceiver), so swapping FakeNdiReceiver for
SdkNdiReceiver is the only difference between a unit test and live
ingest.

- Sampled, never pacing (inv #1/#2/#10): each receive() is a bounded
  non-blocking capture on the ingest thread; no frame this instant →
  ReceivedFrame::None (last-good held, tile rides its state machine).
- Copies pixels out of SDK-owned memory before the RecvVideoFrame drops
  (free-exactly-once); negative NDI timecode → genpts fallback; a
  malformed geometry is a typed skip, never a panic.
- Drop order: receiver before capability (the receiver holds fn pointers
  into the capability's still-mapped Library).
- New opt-in feature `ndi-bindings` = ndi + multiview-ndi-sys/bindings
  (build-time bindgen over the licensed header); plain `--features ndi`
  stays SDK-free and CI-buildable. Default build stays LGPL-clean.
- ndi-sys: NdiRuntime is now Send (the SDK table is process-global +
  immutable, the Library is Send) so a live receiver can move onto the
  ingest thread; deliberately not Sync (owned, never shared by ref).

Hardware-validated on the SDK-equipped x86_64 box (tests/ndi_live.rs,
live_ingest): a sys NdiSender publishes a UYVY gradient, the finder
discovers it, and the production NdiProducer over SdkNdiReceiver yields
a 64x64 NV12 ProducedFrame (pixels=6144 = w*h*3/2) — proving the whole
ingest path: recv_capture → recv_free → ReceivedVideoFrame → UYVY→NV12
→ ProducedFrame. The CI-safe probe test is retained.

clippy -D warnings green: default, --features ndi (SDK-free), and
--features ndi-bindings (on box); fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
troykelly pushed a commit that referenced this pull request Jun 8, 2026
…ll + scale-at-composite, ADR-0034)

Implements RT-6 from the decoupled-routing backlog (ADR-0034): instant, LIVE,
sub-frame-seamless VIDEO->cell switching within the existing single-program path,
carrying the two heaviest verdict-mandated fixes.

FIX #1 — O(1) rebind_cell (multiview-engine):
- CompositorDrive gains a cell-id -> index map (with_cell_ids/set_cell_ids) and
  rebind_cell(cell_id, source_id): a pure source re-point that mutates the bound
  source in place and SKIPS solve_layout/validate entirely (geometry unchanged).
  Verifies the target store exists; an unknown cell/source is an honest
  Error::Rebind and the prior binding is held (no panic). Applied at the existing
  frame-boundary control hook; never blocks the clock (inv #1).

FIX #2 — scale-at-composite (multiview-compositor):
- Tile gains dst_w/dst_h + placed()/scaled() constructors; the CPU reference
  resamples each source's NV12 planes into its destination cell rect
  (nearest-neighbour, stays NV12 — inv #5/#6). The GPU path now scales too
  (TileParams.dst_size + composite.wgsl), so a cross-geometry swap renders
  correctly on both backends (1:1 reduces to the prior placement, byte-identical).
- The drive computes each cell's pixel rect and passes it as the tile destination.

FIX #3 — coalesce + bound (multiview-cli):
- command_drain becomes CommandDrain: SwapSource / salvo source-recalls route
  through the O(1) rebind_cell (no re-solve), coalesced and capped at
  MAX_REPOINTS_PER_TICK per frame-boundary pass, with a bounded drop-oldest
  backlog so a salvo storm cannot blow the tick budget.

Tests (RED-first): cross-geometry swap renders correct (was broken under 1:1);
K-command salvo does <=1 layout re-solve; per-tick re-point storm is capped;
rebind to unknown cell/source is a clean error/hold; a no-op run composes
byte-identically. GPU cross-geometry parity asserted via SSIM. Protected
output-clock tests (runtime.rs/drive.rs) unchanged and green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
aperim-agent added a commit that referenced this pull request Jun 8, 2026
…29)

Document SRT (Secure Reliable Transport — the network transport, not
SubRip subtitles) as a first-class ingest + egress subsystem. SRT bytes
already flow through FFmpeg's `srt://` (caller-only), and a fully-built
typed `SrtConfig` already exists in multiview-input/src/srt.rs wired to
nothing — so this is a wiring + tiering design, not a greenfield build.

Decision (ADR-0039): a tiered SrtTransport seam mirroring the preview-WHEP
seam + NDI-sys precedent.
- Tier-0: keep FFmpeg `srt://` as the license-clean (libsrt MPL-2.0,
  already `--enable-libsrt`, no GPL escalation) caller baseline +
  conformance oracle; wire the orphaned SrtConfig into the schema
  backward-compatibly (optional SrtOptions, no version bump).
- Tier-1: own the two things FFmpeg structurally cannot do (verified:
  libavformat never calls srt_bstats/srt_bistats; its listener is
  single-connection) behind a socket-free seam over pure-Rust srt-tokio
  — link statistics -> telemetry/HealthWarning (ADR-0035) + listener-mode
  stream-id Access-Control multiplexing (ADR-0034 routing).
- Tier-2: defer an owned multiview-srt-sys libsrt FFI leaf for bonding
  (ADR-0028 pattern, only-if-required).

TSBPD is an input jitter buffer feeding the last-good-frame store
(inv #1/#2), never the output clock; all SRT->engine paths are bounded
drop-oldest, the engine never awaits a socket (inv #10).

Fixes two verified latent defects in the orphaned model as backlog items:
passphrase bound 10..=79 -> 10..=80 (libsrt HAICRYPT_SECRET_MAX_SZ=80);
latency emitted as ms but libav's srt:// wants microseconds (1000x bug).

Research fanned out across 22 agents (grounding + web research +
adversarial verification) then two fresh-context reviewers; all
load-bearing claims verified against primary sources.

Docs only — the SRT-0..12 implementation is tracked in the backlog.

Co-authored-by: Troy Kelly <ops.claude.ai@s.aperim.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
aperim-agent added a commit that referenced this pull request Jun 8, 2026
…+ ADR-0040/0041 + backlogs (#30)

Document two layered transport subsystems, each as the house brief→ADR→backlog
triple, fanned out across two research workflows (32 agents total) and
adversarially reviewed against primary sources.

ADR-0040 — IP multicast transport (the foundation): typed SourceKind::Udp/Rtp +
Output::Udp/Rtp with structured group/port/SSM-sources/interface/ttl/buffer fields
(mirroring the IMPLEMENTED RtspOptions precedent — a typed SrtOptions does NOT exist
yet, only the ADR-0039 docs merged), libav udp/rtp URL DERIVED from typed config
with SSM/interface/buffer defaults baked in; wires the already-built-but-unreachable
PushProtocol::UdpTs + a new ::Rtp. Scoped to compressed TS-over-UDP/RTP (MP2T PT33);
uncompressed ST 2110-20 out of scope, reusing only the st2110 RTP-parse/seq/2022-7/
bounded-channel prior art. Receive socket = input jitter buffer, never a pacer
(inv #1/#2); bounded drop-oldest both ways (inv #10); encode-once-mux-many (inv #7).
Plus the operator-requested BULLETPROOF container-without-host-networking
HealthWarning (the silent-bridge-multicast trap, verified live in this devcontainer:
172.17.0.2/16, no --network=host): a conjunctive, false-positive-free advisory
startup heuristic (container-no-host-networking) + an authoritative runtime
confirmation (multicast-joined-no-traffic), modelled on ADR-0035 sense->detect->warn,
never auto-fails. MC-0..12 backlog.

ADR-0041 — SAP (Session Announcement Protocol, RFC 2974 — VLC "Network Streams
(SAP)", NOT subtitles) discovery + announcement, sitting on the multicast transport.
Build ONE essence-agnostic SAP packet engine + ONE general RFC 8866 SDP model
(video/audio/MP2T) and collapse the planned AES67 SAP/SDP (AES67-5/-6) into thin
profiles — supersedes ADR-0033 §6/§9's audio-only placement, no second SAP.
RECEIVE -> a bounded, UNTRUSTED discovered-session inventory the operator
confirm-binds to a Source (Class-1, never auto-ingest); ANNOUNCE -> a tokio-timer
emits SDP for our multicast outputs so VLC/Dante discover them (ships with the MC-4
egress). Verified wire details baked in (3-bit version, auth_len-as-words not
VLC's bytes bug, ±1/3 jitter, max(10×period,1h) purge, reject E/inbound-T=1, full
group set incl. de-facto 239.255.255.255). Listener/announcer/publish all off the
data plane, bounded drop-oldest (inv #1/#10). SAP-0..10 backlog (depends on MC-3/MC-4).

Docs only — the MC-0..12 and SAP-0..10 implementations are tracked in the backlogs.

Co-authored-by: Troy Kelly <ops.claude.ai@s.aperim.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
troykelly pushed a commit that referenced this pull request Jun 8, 2026
…ove 4K decode off the CPU (efficiency)

Prefer NVDEC (*_cuvid) for video decode when the cuda feature is compiled,
a cuvid wrapper for the codec is registered in the linked libav, and no
runtime opt-out (MULTIVIEW_DISABLE_NVDEC) is set — else fall back to the
software decoder. Selection is pure/unit-tested (codec id -> *_cuvid name).
The cuvid decoder is opened bound to a CUDA AVHWDeviceContext; decoded CUDA
surfaces are downloaded to host NV12 via av_hwframe_transfer_data (the
budgeted CPU<->GPU copy), so the pipeline still consumes NV12 (inv #5).

Graceful fallback is mandatory and proven on a GPU-free box: a hardware
open failure (no GPU / driver mismatch / no cuvid wrapper) logs once and
degrades to software decode — the tile keeps running, the output never
falters (inv #1/#2). New unit tests cover the pure selection + opt-out;
a new integration test proves the full cuvid-open-then-fallback path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
troykelly pushed a commit that referenced this pull request Jun 9, 2026
…n + NVENC NV12 — pin the whole pipeline to the least-contended GPU (ADR-0035 Tier-1)

Tier-1 of ADR-0035: decide-once, load-aware GPU pick at admission. Polls the
existing NVML load source ONCE (synchronously, before the output clock starts),
scores every visible GPU via the built-but-previously-inert
multiview_hal::select_device, and pins the whole decode->composite->encode
island to the single least-contended GPU that can host it. On the contended
frigate box this routes work off the 95%-VRAM RTX 4060 (over the 0.85 headroom
ceiling) onto the idle P2000, instead of blindly grabbing GPU0.

Affinity is structural: one GpuTarget per island reaches the wgpu compositor
adapter pick (Seam A, by PCI bus id / vendor+device pair / name). The chosen
device's CUDA ordinal is resolved alongside on GpuTargetInfo so the Tier-2
NVDEC/NVENC hardware paths consume the SAME ordinal once they are wired into the
run (they are software-only today; the re-select-on-failure state machine is
the documented Tier-2 seam, deliberately not built here).

Also feeds NVENC NV12: when the resolved encoder ends in _nvenc the encode
input format is NV12 (the canvas is already NV12, inv #5), so the per-tick
full-canvas swscale in the sink collapses to passthrough — the biggest
avoidable CPU cost on the output path. Software codecs keep YUV420P.

Graceful fallback throughout (inv #1/#2): no NVML / no cuda feature / no visible
GPU / scorer rejection / unresolved handles -> the legacy default-adapter path,
logged once, never a panic or a stalled clock. The GPU-free dev container + CI
take the None path unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
troykelly pushed a commit that referenced this pull request Jun 16, 2026
…ood under overload (ADR-T018)

`run_inner` composed exactly one fresh frame per loop iteration, so when
compose+encode overran the tick budget (a CPU/GPU-contended host) the loop
free-ran at composition speed and media-time slipped behind wall-clock — the
frigate ~84-minute-lag incident. After parking on the due tick's deadline the
loop now re-emits the held last-good frame for each whole tick-period that has
already elapsed (each under a fresh strictly-increasing pts), then composes
exactly one fresh frame for the current tick — so the emitted tick tracks
wall-clock at 1.0x and the published sequence stays contiguous (no muxer PTS
gap). Off the overload path the predicate is false on the first check and
behaviour is byte-identical to before (the FPS_60 soak proves it).

- Last-good is held as the CompositedFrame and reused IN PLACE for a repeat
  (only tick/pts changes), so a repeat is not a multi-MB plane copy on the hot
  loop; the downstream state_of fan-out clones once exactly as for a fresh frame
  (inv #2 lifted to the output clock; inv #3: a repeat carries a NEW pts).
- A pathological one-off jump (multi-second deschedule / VM pause) emits at most
  MAX_REPEATS_PER_TICK repeats, then resyncs the counter to wall-clock in one
  skip_to step — bounded, never spins.
- No new channel/await; the skip decision is a pure local now-comparison
  (inv #1/#10 intact).

Also adapts control_hook_runs_every_tick: it paced by freezing the clock 1 s
ahead, which cadence-hold correctly reads as overload (backfilled with repeats
that do not run the control hook). It now paces one period per fresh compose —
preserving both assertions and adding a frames_repeated()==0 check that proves
the fresh-per-tick path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
troykelly pushed a commit that referenced this pull request Jun 16, 2026
…fects

Codex panel (PR #177) found 7 defects in the verified-body -> entitlement
mapping where the crypto guarantees get dropped. Add failing tests first
(TDD red), each isolating one defect. The crypto CHAIN + canonical CBOR are
confirmed correct and untouched.

Key-trust (heartbeat_keytrust.rs):
* #1 key-purpose: a root-attested UPDATE key (and a non-current/next status key)
  must NOT be trusted as a lease signer; a lease signed by one is rejected
  end-to-end. (+ a dual-pin  guard that must stay trusted.)
* #7: a negative valid_from must be REJECTED, not coerced to unsigned 0.
* #3: canonically-padded standard-base64 leaseBytes (CBOR len%3!=0) must DECODE
  (stripping '=' before STANDARD.decode wrongly rejects them).
* #4: a body that genuinely OMITS instance_binding_id / serial must be rejected
  (fail closed), not installed with an empty id. The omitting helper pads
  licence_id so the body is unpadded — isolating #4 from the #3 base64 path.

Client loop (heartbeat_client.rs):
* #2: the installed expiry MUST equal the SIGNED not_after (RED: left
  2026-07-21 = system_now()+35d vs right 2026-10-26 = signed not_after); a
  replayed older still-signed lease must not re-extend.
* #5: the renewal heartbeat must address the binding by the server's
  instanceBindingId (RED: left = lease serial vs right = binding id).
* #6: replace the instant-error "stall" with a REAL in-flight black-hole
  (Notify-backed) and assert a concurrent store reader sails through while the
  heartbeat call is genuinely parked (inv #10).

Adds LeaseBody::is_expired_at (the only new prod API needed to express the
expiry tests). Fake gains: keys_with_signer/keys_with_negative_valid_from,
sign_lease_expiring_at/sign_old_lease/sign_body/sign_body_omitting, and a
real-stall + binding-id-capture + replay knob on FakeLicenceServer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
troykelly pushed a commit that referenced this pull request Jun 16, 2026
…fects

Codex panel (PR #177) found 7 defects in the verified-body -> entitlement
mapping where the crypto guarantees get dropped. Add failing tests first
(TDD red), each isolating one defect. The crypto CHAIN + canonical CBOR are
confirmed correct and untouched.

Key-trust (heartbeat_keytrust.rs):
* #1 key-purpose: a root-attested UPDATE key (and a non-current/next status key)
  must NOT be trusted as a lease signer; a lease signed by one is rejected
  end-to-end. (+ a dual-pin  guard that must stay trusted.)
* #7: a negative valid_from must be REJECTED, not coerced to unsigned 0.
* #3: canonically-padded standard-base64 leaseBytes (CBOR len%3!=0) must DECODE
  (stripping '=' before STANDARD.decode wrongly rejects them).
* #4: a body that genuinely OMITS instance_binding_id / serial must be rejected
  (fail closed), not installed with an empty id. The omitting helper pads
  licence_id so the body is unpadded — isolating #4 from the #3 base64 path.

Client loop (heartbeat_client.rs):
* #2: the installed expiry MUST equal the SIGNED not_after (RED: left
  2026-07-21 = system_now()+35d vs right 2026-10-26 = signed not_after); a
  replayed older still-signed lease must not re-extend.
* #5: the renewal heartbeat must address the binding by the server's
  instanceBindingId (RED: left = lease serial vs right = binding id).
* #6: replace the instant-error "stall" with a REAL in-flight black-hole
  (Notify-backed) and assert a concurrent store reader sails through while the
  heartbeat call is genuinely parked (inv #10).

Adds LeaseBody::is_expired_at (the only new prod API needed to express the
expiry tests). Fake gains: keys_with_signer/keys_with_negative_valid_from,
sign_lease_expiring_at/sign_old_lease/sign_body/sign_body_omitting, and a
real-stall + binding-id-capture + replay knob on FakeLicenceServer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
troykelly pushed a commit that referenced this pull request Jun 16, 2026
…nel)

The round-2 3-lens panel confirmed the 7 round-1 fixes correct and peeled back
3 BLOCKER + 2 MAJOR deeper holes. Add failing tests first (TDD red); the CBOR
encoder + trust chain stay untouched.

BLOCKERS:
* #1 cross-instance lease replay: a_lease_for_another_devices_binding_is_rejected_
  not_installed — a correctly-signed lease for ANOTHER device's binding must not
  install onto this one (RED: currently installs).
* #2 reject-path identity poisoning: a_rejected_lease_does_not_poison_the_learned_
  binding_id — a rejected (expired+foreign) lease must NOT mutate learned_binding_id
  (RED: remember_binding_id runs before install()).
* #3 key status not cryptographically bound: `status` is NOT in the root-signed
  key pre-image, so it is forgeable. New tests prove the SIGNED gates still reject
  a status-flipped-to-"current" key when outside its signed validity window / when
  revoked (already green — they guard the fix), AND that an in-validity unrevoked
  lease key is trusted regardless of the status hint (RED: current code gates on
  status). Trust = key_type=="lease" ∧ now∈[valid_from,valid_until] ∧ not revoked.

MAJORS:
* #4 gpu_limit fail-open: a_present_but_{negative,oversized}_gpu_limit_is_rejected_
  not_unlimited — a present-but-invalid gpu_limit must be MalformedBody, not folded
  to Unlimited (RED). an_absent_gpu_limit_means_unlimited guards the contrast.
* #5 real LeaseExpired: a_signed_lease_with_an_absolute_past_not_after_is_rejected_
  lease_expired — a lease whose signed not_after is an absolute 1970 instant
  deterministically hits HeartbeatError::LeaseExpired (round-1's replay test passed
  via InstallError::Stale due to a fixed-future fake epoch; this asserts the real
  expiry path).

Fake gains: keys_with_signer_validity / keys_with_signer_revoked (forge the
unsigned status with a signed validity/revocation), sign_body_with_raw_gpu_limit
(present-but-out-of-range gpu_limit via a hand-built CBOR int), sign_lease_for /
foreign_binding_id, and set_foreign_binding / set_replay_absolute_past knobs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
troykelly pushed a commit that referenced this pull request Jun 16, 2026
…(PR #170)

Resolve the Codex 3-lens panel block on PR #170 (3 majors + 2 minors), making
the red tests from the previous commit pass without touching them.

MAJOR #1 — unbounded inbound UDP drain → invariant #10 starvation
(transport/unified.rs). The driver drained the socket in a
try_io(recvmsg)-until-WouldBlock loop with no per-wake budget and no .await, so
a sustained/hostile flood (the socket never returns WouldBlock) kept the one
driver task in inbound routing forever, starving pump_all, str0m timers,
command draining, TURN/ICE keepalives, GC and stop. Extract a
`drain_inbound_budgeted` helper capped at MAX_DATAGRAMS_PER_WAKE (256) datagrams
OR MAX_DRAIN_TIME (2 ms) per wake; it breaks to pump/yield, and the select!
re-arms immediately while the socket still has data (deferred, not dropped — the
OS receive buffer holds the rest, dropping oldest as UDP already does). A
malformed/CTRUNC datagram drops just that one and keeps draining (still counted
against the budget, so a hostile peer cannot escape the cap). Kernel-backed
starvation test proves one wake reads exactly the cap and yields.

MAJOR #2 — cmsg_align broke macOS (transport/local_addr.rs). Replace the
hand-rolled `align_of::<cmsghdr>()` alignment (which only coincided with the
real CMSG_ALIGN on x86_64) with libc's own CMSG_FIRSTHDR/CMSG_NXTHDR/CMSG_DATA,
whose per-target alignment is correct (sizeof(long) on Linux, 4 bytes via
__DARWIN_ALIGN32 on Darwin/BSD). The hand-rolled align_up is now used only for
compile-time buffer sizing with explicit per-platform quanta.

MAJOR #3 — false `// SAFETY:` alignment claim (transport/local_addr.rs). The
control buffer is now a #[repr(C)] union embedding a cmsghdr (truly
cmsghdr-aligned, sized via CMSG_SPACE for both an IPv6 and an IPv4 PKTINFO
cmsg), so the macros' alignment requirement actually holds and every
`// SAFETY:` states the true invariant (pointers from the libc cmsg walker;
struct reads are deliberately unaligned and load-bearing).

MINOR #4 — parser/mapping untested. Kernel-backed recvmsg(2) round-trip tests
recover a concrete IPv6 destination and an un-mapped IPv4-mapped destination
(the to_ipv4_mapped path), exercising the unsafe parser end-to-end.

MINOR #5 — MSG_CTRUNC swallowed (rule 37). recv_from_with_local now reads
msg_flags directly (socket2's RecvFlags::is_truncated only checks MSG_TRUNC, not
MSG_CTRUNC) and returns an InvalidData receive error on truncation, so a
truncated PKTINFO is dropped rather than silently mapped onto the less-specific
[::] bind addr.

The recv path is now built on a hand-rolled libc::msghdr over libc::recvmsg
(socket2 still owns the bind/setsockopt) so the crate owns msg_flags and walks
cmsgs with the platform macros. No new dependencies (libc was already a
native-gated dep). Public API of UnifiedEndpoint::run is unchanged. ADR-0048
gains an Amendment 2026-06-16 documenting the budget + cmsg + CTRUNC corrections.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
troykelly pushed a commit that referenced this pull request Jun 16, 2026
…fects

Codex panel (PR #177) found 7 defects in the verified-body -> entitlement
mapping where the crypto guarantees get dropped. Add failing tests first
(TDD red), each isolating one defect. The crypto CHAIN + canonical CBOR are
confirmed correct and untouched.

Key-trust (heartbeat_keytrust.rs):
* #1 key-purpose: a root-attested UPDATE key (and a non-current/next status key)
  must NOT be trusted as a lease signer; a lease signed by one is rejected
  end-to-end. (+ a dual-pin  guard that must stay trusted.)
* #7: a negative valid_from must be REJECTED, not coerced to unsigned 0.
* #3: canonically-padded standard-base64 leaseBytes (CBOR len%3!=0) must DECODE
  (stripping '=' before STANDARD.decode wrongly rejects them).
* #4: a body that genuinely OMITS instance_binding_id / serial must be rejected
  (fail closed), not installed with an empty id. The omitting helper pads
  licence_id so the body is unpadded — isolating #4 from the #3 base64 path.

Client loop (heartbeat_client.rs):
* #2: the installed expiry MUST equal the SIGNED not_after (RED: left
  2026-07-21 = system_now()+35d vs right 2026-10-26 = signed not_after); a
  replayed older still-signed lease must not re-extend.
* #5: the renewal heartbeat must address the binding by the server's
  instanceBindingId (RED: left = lease serial vs right = binding id).
* #6: replace the instant-error "stall" with a REAL in-flight black-hole
  (Notify-backed) and assert a concurrent store reader sails through while the
  heartbeat call is genuinely parked (inv #10).

Adds LeaseBody::is_expired_at (the only new prod API needed to express the
expiry tests). Fake gains: keys_with_signer/keys_with_negative_valid_from,
sign_lease_expiring_at/sign_old_lease/sign_body/sign_body_omitting, and a
real-stall + binding-id-capture + replay knob on FakeLicenceServer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
troykelly pushed a commit that referenced this pull request Jun 16, 2026
…nel)

The round-2 3-lens panel confirmed the 7 round-1 fixes correct and peeled back
3 BLOCKER + 2 MAJOR deeper holes. Add failing tests first (TDD red); the CBOR
encoder + trust chain stay untouched.

BLOCKERS:
* #1 cross-instance lease replay: a_lease_for_another_devices_binding_is_rejected_
  not_installed — a correctly-signed lease for ANOTHER device's binding must not
  install onto this one (RED: currently installs).
* #2 reject-path identity poisoning: a_rejected_lease_does_not_poison_the_learned_
  binding_id — a rejected (expired+foreign) lease must NOT mutate learned_binding_id
  (RED: remember_binding_id runs before install()).
* #3 key status not cryptographically bound: `status` is NOT in the root-signed
  key pre-image, so it is forgeable. New tests prove the SIGNED gates still reject
  a status-flipped-to-"current" key when outside its signed validity window / when
  revoked (already green — they guard the fix), AND that an in-validity unrevoked
  lease key is trusted regardless of the status hint (RED: current code gates on
  status). Trust = key_type=="lease" ∧ now∈[valid_from,valid_until] ∧ not revoked.

MAJORS:
* #4 gpu_limit fail-open: a_present_but_{negative,oversized}_gpu_limit_is_rejected_
  not_unlimited — a present-but-invalid gpu_limit must be MalformedBody, not folded
  to Unlimited (RED). an_absent_gpu_limit_means_unlimited guards the contrast.
* #5 real LeaseExpired: a_signed_lease_with_an_absolute_past_not_after_is_rejected_
  lease_expired — a lease whose signed not_after is an absolute 1970 instant
  deterministically hits HeartbeatError::LeaseExpired (round-1's replay test passed
  via InstallError::Stale due to a fixed-future fake epoch; this asserts the real
  expiry path).

Fake gains: keys_with_signer_validity / keys_with_signer_revoked (forge the
unsigned status with a signed validity/revocation), sign_body_with_raw_gpu_limit
(present-but-out-of-range gpu_limit via a hand-built CBOR int), sign_lease_for /
foreign_binding_id, and set_foreign_binding / set_replay_absolute_past knobs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
troykelly pushed a commit that referenced this pull request Jun 16, 2026
…(PR #170)

Resolve the Codex 3-lens panel block on PR #170 (3 majors + 2 minors), making
the red tests from the previous commit pass without touching them.

MAJOR #1 — unbounded inbound UDP drain → invariant #10 starvation
(transport/unified.rs). The driver drained the socket in a
try_io(recvmsg)-until-WouldBlock loop with no per-wake budget and no .await, so
a sustained/hostile flood (the socket never returns WouldBlock) kept the one
driver task in inbound routing forever, starving pump_all, str0m timers,
command draining, TURN/ICE keepalives, GC and stop. Extract a
`drain_inbound_budgeted` helper capped at MAX_DATAGRAMS_PER_WAKE (256) datagrams
OR MAX_DRAIN_TIME (2 ms) per wake; it breaks to pump/yield, and the select!
re-arms immediately while the socket still has data (deferred, not dropped — the
OS receive buffer holds the rest, dropping oldest as UDP already does). A
malformed/CTRUNC datagram drops just that one and keeps draining (still counted
against the budget, so a hostile peer cannot escape the cap). Kernel-backed
starvation test proves one wake reads exactly the cap and yields.

MAJOR #2 — cmsg_align broke macOS (transport/local_addr.rs). Replace the
hand-rolled `align_of::<cmsghdr>()` alignment (which only coincided with the
real CMSG_ALIGN on x86_64) with libc's own CMSG_FIRSTHDR/CMSG_NXTHDR/CMSG_DATA,
whose per-target alignment is correct (sizeof(long) on Linux, 4 bytes via
__DARWIN_ALIGN32 on Darwin/BSD). The hand-rolled align_up is now used only for
compile-time buffer sizing with explicit per-platform quanta.

MAJOR #3 — false `// SAFETY:` alignment claim (transport/local_addr.rs). The
control buffer is now a #[repr(C)] union embedding a cmsghdr (truly
cmsghdr-aligned, sized via CMSG_SPACE for both an IPv6 and an IPv4 PKTINFO
cmsg), so the macros' alignment requirement actually holds and every
`// SAFETY:` states the true invariant (pointers from the libc cmsg walker;
struct reads are deliberately unaligned and load-bearing).

MINOR #4 — parser/mapping untested. Kernel-backed recvmsg(2) round-trip tests
recover a concrete IPv6 destination and an un-mapped IPv4-mapped destination
(the to_ipv4_mapped path), exercising the unsafe parser end-to-end.

MINOR #5 — MSG_CTRUNC swallowed (rule 37). recv_from_with_local now reads
msg_flags directly (socket2's RecvFlags::is_truncated only checks MSG_TRUNC, not
MSG_CTRUNC) and returns an InvalidData receive error on truncation, so a
truncated PKTINFO is dropped rather than silently mapped onto the less-specific
[::] bind addr.

The recv path is now built on a hand-rolled libc::msghdr over libc::recvmsg
(socket2 still owns the bind/setsockopt) so the crate owns msg_flags and walks
cmsgs with the platform macros. No new dependencies (libc was already a
native-gated dep). Public API of UnifiedEndpoint::run is unchanged. ADR-0048
gains an Amendment 2026-06-16 documenting the budget + cmsg + CTRUNC corrections.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
troykelly pushed a commit that referenced this pull request Jun 17, 2026
…turation residual; refresh SourcesPage L2 copy

Pre-PR review close-outs (orchestrator-flagged):

- pipeline.rs: the `select_admission_pick` budget comment cited "ADR-0035 §5" for
  the per-GPU perf-class CostBudget table being a future refinement — that
  citation is WRONG (ADR-0035 has no §5 and does not scope the budget table).
  Corrected to cite ADR-0018's hard-gate principle ("hard gates are the real
  safety": VRAM headroom + capability + NVENC-session), and state the perf-class
  table is future work (not yet built) — rule 27, no aspirational citation.
- pipeline.rs: `select_live_decode_pick` now DISCLOSES the known residual inline
  — a VRAM-roomy but decode-engine-saturated GPU passes both the permissive
  Mpix/s budget and the headroom gate, so a live add CAN be admitted onto a GPU
  that cannot sustain another decode. Acceptable for this ship: the never-off-air
  contract holds (an over-subscribed decode degrades the NEW tile, never the
  program — inv #1/#2, the clock samples last-good) and inv #9's closed-loop
  degradation sheds the cheapest tile if it bites. Comment-only; no code change.
- web SourcesPage: the apply-semantics callout + saved description said network/
  file sources "go live via config export + restart" — now FALSE after L2 (they
  apply live on a full-engine run). Refreshed the copy to match: synthetic +
  network/file apply live on a full-engine run; NDI/YouTube/AES67 (and any kind
  on the software engine) restart. The UI already reads the real
  X-Multiview-Apply header — this is the explanatory copy catching up (rule 27).

Web gate (npm ci then): eslint --max-warnings=0 EXIT 0; tsc --noEmit EXIT 0;
lingui compile EXIT 0; vitest run 632 passed / 63 files EXIT 0 (incl the 2
SourcesPage suites 10/10). Rust: fmt clean; clippy -p multiview-cli
--features ffmpeg,overlay,gpu EXIT 0 (the comments compile clean).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
aperim-agent added a commit that referenced this pull request Jun 18, 2026
…pine) (#181)

* test(control,cli): RED, 3 new files +624 for ADR-W018 level 2 — network live add

TDD-first: failing tests for network source live add/edit functionality.
Tests expect APIs not yet implemented:
- typed_resources.rs delta: LiveSourceCapability + with_live_sources + capability-driven header
- live_apply_header_http.rs: bind_and_serve HTTP honesty (live only when spawner wired)
- live_network_apply.rs (ffmpeg-gated): e2e real ingest loop + state machine + never-falter proof

These tests pin the exact behavior: network/file kinds live on full-pipeline,
ndi/youtube/aes67 always restart, capability derives from ingest.is_some().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(config,control): add LiveSourceCapability for ADR-W018 L2 network live-add

- config/schema.rs: add SourceKind::is_network_media() classifier (rtsp/hls/ts/srt/rtmp/file/rist)
- control/state.rs: add LiveSourceCapability struct {synthetic, network} with constructors + is_live(kind)
- control/state.rs: add AppState.live_sources field (default synthetic_only)
- control/state.rs: add AppState::with_live_sources() builder
- control/lib.rs: re-export LiveSourceCapability

These provide the run-path capability signal the control plane threads to declare
which source kinds the engine can live-apply, ensuring X-Multiview-Apply headers
stay honest per build/run path (synthetic-only on software, synthetic+network on
ffmpeg).

BREAKING: bind_and_serve and related functions will need the live_sources param
in next commits; routes/sources.rs will call state.live_sources.is_live().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(control): update live_apply_upsert to check LiveSourceCapability

Changes live_apply_upsert to call state.live_sources.is_live(kind) instead of
just checking is_synthetic(). This allows network/file kinds to be live-applied
when the capability is set (full-pipeline run), and keeps ndi/youtube/aes67
always restart.

Also updates the kind-change check to use is_live() for both synthetic->network
and network->non-live transitions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(cli): ADR-W018 L2 — live add/edit network sources via the uniform ingest path

Network/file kinds (rtsp/hls/ts/srt/rtmp/rist/file) now apply LIVE on the
running full-pipeline engine through ONE uniform ingest path — the level-2 spine
of the DEV-stack, re-authored against current main (the original PR #108 commits
9-way-conflict; pipeline.rs churned 49x since).

- cli live_sources: SourceSpawn + IngestSpawner seam + SpawnedProducer;
  LiveSourceHub::start_with_ingest threads the run's spawner; the worker's
  SpawnSource arm tears down any prior producer (edit = store-reuse swap) then
  spawns through the seam; no spawner => held + warned (slate, inv #10).
- cli pipeline: spawn_ingest_producer extracted — IngestSupervisor::start and
  the new LiveIngestSpawner call the SAME fn, so a runtime add runs exactly the
  startup supervised ingest_loop (reconnect/jitter/PTS-normalize/rw-timeout).
  Placement (ADR-W018 §7, gpu): drive_streaming publishes the pinned LiveIsland;
  select_live_decode_pick re-polls NVML and consults the SAME select_device
  scorer with the candidate set restricted to the island device (never frags/
  migrates) — admit => island ordinal stamped, reject/island-vanished => that
  source decodes in software, warned. The output never falters (inv #1).
- cli control: bind_and_serve takes the run's LiveSourceCapability; upsert_source
  routes a network kind to request_spawn_source (store create/reuse + route key
  + config mirror at the frame boundary, heavy spawn off-thread BEFORE the
  rebind — inv #1/#10); ndi/youtube/aes67 held with the reason.
- cli main: ControlPlaneWiring carries the ingest spawner; the capability sent
  to bind_and_serve is DERIVED from ingest.is_some() — the header claims live
  for network kinds exactly when a real spawner backs it. Pipeline path wires
  Pipeline::live_ingest_spawner(); software path wires None.
- control routes/sources: live_apply_upsert answers X-Multiview-Apply from the
  run capability; OpenAPI + doc updated. ADR-W018 amended for the as-built L2.

Restores the e2e chroma pin PR #179 deferred (live_decode_chroma.rs): a
live-added decode's U/V plane stats match the startup decode of the same clip.

Gates (lane-local target, env -u CARGO_TARGET_DIR, -j1): fmt clean; clippy GREEN
on default, ffmpeg, and ffmpeg+overlay+gpu; control typed_resources 23/23;
8 gpu placement unit tests incl the seam-pin spy; both ffmpeg e2e realtime proofs
GREEN (live add reaches LIVE, remove slates to NO_SIGNAL, churn never falters).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(cli,web): correct the inert-budget citation + disclose decode-saturation residual; refresh SourcesPage L2 copy

Pre-PR review close-outs (orchestrator-flagged):

- pipeline.rs: the `select_admission_pick` budget comment cited "ADR-0035 §5" for
  the per-GPU perf-class CostBudget table being a future refinement — that
  citation is WRONG (ADR-0035 has no §5 and does not scope the budget table).
  Corrected to cite ADR-0018's hard-gate principle ("hard gates are the real
  safety": VRAM headroom + capability + NVENC-session), and state the perf-class
  table is future work (not yet built) — rule 27, no aspirational citation.
- pipeline.rs: `select_live_decode_pick` now DISCLOSES the known residual inline
  — a VRAM-roomy but decode-engine-saturated GPU passes both the permissive
  Mpix/s budget and the headroom gate, so a live add CAN be admitted onto a GPU
  that cannot sustain another decode. Acceptable for this ship: the never-off-air
  contract holds (an over-subscribed decode degrades the NEW tile, never the
  program — inv #1/#2, the clock samples last-good) and inv #9's closed-loop
  degradation sheds the cheapest tile if it bites. Comment-only; no code change.
- web SourcesPage: the apply-semantics callout + saved description said network/
  file sources "go live via config export + restart" — now FALSE after L2 (they
  apply live on a full-engine run). Refreshed the copy to match: synthetic +
  network/file apply live on a full-engine run; NDI/YouTube/AES67 (and any kind
  on the software engine) restart. The UI already reads the real
  X-Multiview-Apply header — this is the explanatory copy catching up (rule 27).

Web gate (npm ci then): eslint --max-warnings=0 EXIT 0; tsc --noEmit EXIT 0;
lingui compile EXIT 0; vitest run 632 passed / 63 files EXIT 0 (incl the 2
SourcesPage suites 10/10). Rust: fmt clean; clippy -p multiview-cli
--features ffmpeg,overlay,gpu EXIT 0 (the comments compile clean).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): explicit DecodePlacement tri-state — a placement-rejected live decode forces software (2a67e4e)

Carries the 2a67e4e amendment my initial re-author flattened away — a real
overcommit/fragmentation bug, not a refinement.

THE BUG (in my prior commit): select_live_decode_pick returned Option<String>,
so a placement reject returned None, leaving IngestPlan.cuda_ordinal = None. In
open_and_stream that means `new_preferring_hw(.., want_hw=true, None)` — NVDEC on
libav's DEFAULT CUDA device. On a single-GPU over-headroom island the default
device IS that island (overcommit); on a multi-GPU host it may be a DIFFERENT
GPU (silent island fragmentation, forbidden by ADR-0018 never-fragment). "No
ordinal" cannot distinguish *no placement decision* (default device fine) from
*placement rejected* (hardware must NOT open).

THE FIX: an explicit, closed tri-state DecodePlacement::{Default, Pinned(ordinal),
SoftwareOnly} on IngestPlan + a single decoder_open_args gate:
- SoftwareOnly -> (want_hw=false, None): hardware never attempted, even when
  NVDEC is compiled/present/not env-disabled.
- Pinned(ordinal)/Default -> keep the canonical want_hw_decode env reading
  (MULTIVIEW_DISABLE_NVDEC opt-out still wins over a pin); Pinned threads the
  island ordinal.
- select_live_decode_pick -> select_live_decode_placement: admit -> Pinned (or
  Default when the island resolved no ordinal); reject AND island-vanished ->
  SoftwareOnly, with truthful FORCING-software warnings.
- drive_streaming stamps Pinned; ingest_plan_for defaults Default; open_and_stream
  routes through decoder_open_args.

TDD: restored tests/decode_placement.rs (the f0a6b7f/2a67e4e4 RED pin) — it
opens the REAL decoder via the gate's exact args and asserts a SoftwareOnly
source comes back software (used_hw=false, no cuvid named); the gpu-lane unit
tests now assert the placement value AND the decode-open behaviour per placement
(not just the picked ordinal). ADR-W018 §7 amended for the as-built tri-state.

Gate (env -u CARGO_TARGET_DIR, lane-local, -j1): fmt clean; clippy --workspace
EXIT 0; clippy -p multiview-cli --features ffmpeg,overlay,gpu EXIT 0;
decode_placement 2/2, gpu placement 8/8, live_network_apply + live_decode_chroma
1/1 each (ffmpeg).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(control,cli): gate RIST live-apply on the rist feature — never over-claim X-Multiview-Apply:live (panel major)

Codex panel (#181) MAJOR: RIST was advertised live-appliable unconditionally —
`is_network_media()` includes `Self::Rist` and the binary derives
`LiveSourceCapability::synthetic_and_network()` from `ingest.is_some()`. On an
`ffmpeg`-WITHOUT-`rist` build the hub wires a real ingest spawner (so
rtsp/hls/ts/srt/rtmp/file correctly flip to `live`), but a RIST source's
`ingest_plan_for` arm is a typed refusal (`librist` not built) — so the header
claimed `X-Multiview-Apply: live` for a spawn that refuses, and the tile rode
the slate. The header over-claimed.

Fix: `LiveSourceCapability` gains a separate `rist: bool` the binary sets from
`cfg!(feature = "rist")` (via the new `with_rist` builder); `is_live` returns
`self.rist` for `SourceKind::Rist` and `self.network` for the other network
kinds. `synthetic_only` ⇒ rist:false; `synthetic_and_network` ⇒ rist:true (the
canonical "full network" meaning), narrowed by the binary's
`serve_control_plane` to the build's actual rist truth. RIST classification
(`is_network_media`) is unchanged — RIST IS network media; only its *live-apply
capability* is feature-gated, the honest level.

Tests (control typed_resources, 23→25): `rist_stays_restart_when_the_engine_
build_lacks_the_rist_feature` (with_rist(false) ⇒ RIST restart + no UpsertSource,
while a non-RIST network kind still flips live) and `rist_applies_live_when_the_
engine_build_carries_the_rist_feature` (with_rist(true) ⇒ RIST live + UpsertSource).

Gate (env -u CARGO_TARGET_DIR, lane-local, -j1): fmt clean; clippy --workspace
EXIT 0; clippy -p multiview-cli --features ffmpeg EXIT 0 AND --features
ffmpeg,rist EXIT 0 (both cfg branches compile); control typed_resources 25/25.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): ADR-W018 §7 round-2 — placement fail-closed + bounded-join startup producers

Codex panel round-2 on #181 found three real data-plane defects beyond the round-1 tri-state; all fixed here:

- EMPTY-ISLAND fall-through (blocker): LiveIngestSpawner consulted select_live_decode_placement only when an island was published; an empty slot (startup admission rejected the GPU / no NVML) kept DecodePlacement::Default -> default-device NVDEC. decode_placement_for now forces SoftwareOnly when admission was attempted but named no island (fail closed, ADR-W018 §7).
- ADMIT-with-no-ordinal (major): an island that passes budget but resolves no CUDA ordinal returned Default -> default-device NVDEC on the LIVE path (the compositor island is already pinned). Now forces SoftwareOnly.
- TWO-WRITER race on a reused single-writer TileStore for a live EDIT of a STARTUP-origin source (blocker): teardown only joined hub-owned producers; a startup producer JoinHandle lives in IngestSupervisor, so the old decode thread could still write while the replacement published. register_stop now returns an exited latch every producer carries as an ExitGuard (flips on return or panic); teardown raises stop then bounded-waits on the latch (await_exits) before the replacement publishes -- covering startup-origin producers, not just hub-owned ones.

ProducerStop.stop is pub(crate) so the cross-module registration tests raise it; the gpu-gated placement matches are if-let-else (clippy single_match_else). Gate (lane-local, env -u CARGO_TARGET_DIR, -j1): fmt + clippy --workspace + clippy -p multiview-cli --features ffmpeg,overlay,gpu + test -p multiview-cli --features ffmpeg + test --workspace all EXIT 0. Completes the stalled dev108-live-add lane WIP (round-2 authored there; the 5 ExitGuard call-site completions + the ProducerStop accessor + clippy/test fixups by the integrator).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): build ExitGuard before spawn so a failed thread-spawn never orphans the latch (round-2 panel)

Codex round-2 panel on #181 flagged the ExitGuard-on-spawn-failure gap: register_stop inserts the stop entry + returns the exited latch BEFORE Builder::spawn, but the ExitGuard that flips exited was constructed INSIDE the closure — so a (rare) thread-spawn Err left an orphaned exited=false entry, and a later teardown of that id busy-waited the full TEARDOWN_JOIN_GRACE (3s) before detaching (a regression from the round-1 no-op join; bounded + off the output-clock thread, never an inv #1/#10 breach).

Fix: construct the ExitGuard BEFORE the spawn and move it into the closure at all 7 producer-spawn sites (run.rs synth generator; pipeline.rs audio/tone/captions/youtube-reresolve + the spawn_ingest_producer decode thread; live_sources.rs hub synth). Its Drop now flips exited whether the thread runs (drops on exit) OR Builder::spawn fails (the closure owning the guard is dropped) — so a failed spawn deregisters cleanly and teardown never busy-waits an orphan.

Gate (lane-local, env -u CARGO_TARGET_DIR, -j1): fmt + clippy --workspace + clippy -p multiview-cli --features ffmpeg,overlay,gpu + test -p multiview-cli --features ffmpeg + test --workspace all EXIT 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(adr): ADR-W018 §7 — admit-no-ordinal forces SoftwareOnly (not Default) on the live path + RIST live-apply is rist-feature-gated (round-3 panel)

Two doc-drift minors the round-3 Codex panel flagged (rule 27 — the ADR prose contradicted the verified code):
- §7 said an admitted live decode with no resolved CUDA ordinal stamps Default; the round-2 fix forces SoftwareOnly on the live path (Default is the startup placement, where nothing is pinned yet). Corrected.
- The level-2 amendment said rist is included unconditionally; the code gates RIST live-applicability on cfg!(feature=rist) (with_rist), keeping an ffmpeg-without-rist build at restart. Corrected.

Docs-only; the Rust build/clippy/tests are unaffected (green at 46ceea3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: verify <verify@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
aperim-agent added a commit that referenced this pull request Jul 10, 2026
#242)

* test(audio): RED bounded-memory test for publish_at union-span over-alloc

`AudioStore::publish_at` allocates the full `[min(base,at), max(head,block_end))`
union-span buffer BEFORE applying the drop-oldest capacity clamp. A late/reordered
RTP-audio packet whose rebased index lands up to ~10 s (DEFAULT_DISCONTINUITY_FRAMES
= 480_000, the rebaser's re-anchor threshold) below the live head therefore makes
the transient buffer ~10 s of audio even though the store capacity
(STORE_CAPACITY_FRAMES = 96_000 = 2 s) is far smaller. Allocation is decoupled from
capacity: a burst of ~9.9 s-reordered packets is a per-packet zero+memcpy CPU
amplification vector on the ingest data plane, and "queues drop, never grow"
(invariant #2/#5/#9) is violated for the transient.

The transient is observable without a global allocator (the crate is
`unsafe_code = forbid`): `Vec::drain` shifts survivors down but never reclaims
capacity, so the over-allocated span stays visible via the new doc-hidden
`window_backing_capacity_frames` probe even after the post-merge clamp shrinks the
length back to capacity.

RED — the new test fails on the transient bound:
  backing 479999 frames > capacity 96000 + one block 4800

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(audio): bound publish_at transient to capacity, drop-oldest before alloc

`AudioStore::publish_at` sized its merge buffer to the FULL
`[min(base,at), max(head,block_end))` union span and only applied drop-oldest
AFTER, via a post-merge `Vec::drain`. A late/reordered RTP-audio packet whose
rebased index lands up to ~10 s below the live head (the rebaser only re-anchors
past DEFAULT_DISCONTINUITY_FRAMES = 480_000, ≫ STORE_CAPACITY_FRAMES = 96_000)
therefore allocated a ~10 s (~3.8 MB stereo) transient per packet even though the
store retains only 2 s — a zero+memcpy CPU-amplification vector on the ingest data
plane, and "queues drop, never grow" (invariant #2/#5/#9) violated for the
transient.

Apply drop-oldest UP FRONT: clamp the working base to
`max(union_base, new_head - capacity_frames)` before allocating, so `merged` is
never wider than `capacity_frames` and no evicted frame is ever allocated. A new
`overlay_from` helper copies each source (existing window, incoming block) into the
clamped buffer, skipping the frames that fall below the new base — that is the
drop-oldest, now applied during the copy rather than by a trailing `drain`. The
final published window is byte-identical to before in every case (the clamp is a
no-op whenever the union span already fits capacity, so in-capacity placement,
reorder-within-capacity, gap silence-fill and last-write-wins all behave exactly
as before); only the transient allocation shrinks.

Measured (test publish_at_reordered_far_below_head_allocates_bounded): a packet
reordered ~10 s below the head now backs a 96_000-frame buffer, was 479_999 — the
transient is bounded by capacity, not the discontinuity span. All existing
publish_at/store tests pass unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
aperim-agent added a commit that referenced this pull request Jul 10, 2026
…nv#9, generation-safe) (#244)

* test(framestore): RED — steady-state ring must prune consumed frames (inv#9)

The per-tile media-time ring (TileStore::ring, RING_CAPACITY=256) bounds only
by COUNT and never prunes frames the output clock has already advanced past.
Output `now` is monotone (inv #1/#3), so a frame older than the current latch
can never be selected again — yet under perfect 1:1 pacing (the shipping
PtsWallClock path) the ring sits pinned at 256 dead frames (~3.2 GB/tile at a
1×1 2160p tile), an invariant #9 bounded-memory defect.

Adds a doc-hidden `retained_frames()` introspection accessor (for this test and
future soak gates) and a failing bounded-memory test that publishes+reads 1:1
well past the ceiling and asserts the ring retains a tiny trailing window, not
the full backlog. Fails RED today: "retained 256 of cap 256".

The two existing decode-ahead guards (read_at_does_not_race_ahead,
state_at_ages_a_finite_clip) batch-publish BEFORE any read, so they are
unaffected — the fix must keep them green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(framestore): prune consumed frames from the media-time ring (inv#9)

The per-tile latch-on-tick ring (ADR-T009) bounded only by COUNT and never
released frames the output clock had advanced past, so even under perfect 1:1
pacing it sat pinned at RING_CAPACITY (256) dead frames — ~3.2 GB/tile at a
1×1 2160p tile, an invariant #9 bounded-memory over-allocation. (This ring is
the media-time sampler, distinct from the inv#2 single-slot last-good store
`LatestSlot`, which is untouched.)

Root cause is a missing prune, not an oversized ceiling: output `now` is
monotone (inv #1/#3), so any entry stamped strictly before the current latch is
provably never selected again. Fix:

* readers (`read_at`/`state_at`) record a monotone latch watermark via
  `fetch_max` (one relaxed atomic on the output-clock thread);
* the producer's next `publish_arc` drops entries older than the watermark,
  inside the existing O(capacity) copy-on-write on the sampled input thread —
  never on the output clock;
* a backwards stamp (source-generation change) resets the watermark so the
  re-anchored frame is never pruned before a reader latches it.

Steady state collapses to the latched frame + any decode-ahead future (~2
frames/tile: 3.2 GB → tens of MB). RING_CAPACITY stays as a documented hard
ceiling bounding the worst case (a stalled output clock that stops advancing the
watermark while the producer runs on). Lock-free single-slot semantics and the
latch-on-tick selection rule are unchanged.

The two decode-ahead guards batch-publish before any read, so the watermark
stays at its MIN sentinel and nothing is pruned — they pass unchanged. Turns
the RED steady-state bounded-memory test green (retained 256 -> 2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(framestore): harden prune guards — boundary, reconnect, monitored tile

Mutation-hardening for the media-time ring prune (kills the surviving mutants
`cargo mutants --in-diff` would otherwise report on tile.rs):

* prune_keeps_the_latched_boundary_frame — the frame AT the watermark must
  survive (kills `>=` -> `>` on the retain bound);
* backwards_stamp_resets_the_watermark_so_a_reconnect_is_not_pruned — a
  re-anchor (reconnect/discontinuity) resets the watermark so the fresh low
  generation is not pruned by the old high latch (kills deleting the reset);
* state_at_alone_bounds_the_ring_for_a_monitored_uncomposited_tile — state_at
  advances the watermark too, so a monitored-but-not-composited tile stays
  bounded (kills deleting state_at's fetch_max).

Each encodes a real behaviour (boundary retention, reconnect resilience,
off-canvas monitoring), not just a mutant trap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(framestore): kill equal-timestamp re-anchor mutant in ring prune

The `< with <=` mutant on publish_arc's backwards check (`at < tail.at`)
treats a duplicate/clamped equal stamp as a discontinuity — resetting the
latch watermark and defeating the prune. No existing test published two
frames at the same instant, so the mutant survived `cargo mutants --in-diff`.

This test establishes the watermark (publish @0/@40/@80ms, read_at 80ms),
republishes at the SAME 80ms instant, then asserts the ring still pruned the
consumed @0/@40ms frames (retained == 2, not 4) and newest-wins at the shared
instant. Fails on the `<=` mutant, passes on `<`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(framestore): fuse ring select+watermark-advance; document prune safety

Data-plane pre-merge hardening for the inv#9 ring prune (team-lead checklist):

- Fuse frame selection and the prune-watermark `fetch_max` into one private
  `latch_and_advance`; `read_at` and `state_at` both route through it. A reader
  now physically cannot select a ring frame without first advancing the
  watermark past it, so `publish_arc`'s prune is correct BY CONSTRUCTION for any
  future reader (e.g. a wired-up degradation sampler) — fail-safe, not
  fail-checked.
- `read_at`'s only new per-tick cost stays one relaxed-atomic `fetch_max` (no
  lock, no allocation); the read site says so.
- State the prune boundary at the `retain`: keep entries at-or-after the
  watermark, drop ONLY those strictly before it. The latched boundary frame sits
  exactly at the watermark and is never pruned.

Behaviour-preserving: all 15 ring tests + the state-machine suite pass unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(framestore): RED — cross-generation watermark race drops new-gen boundary frame

The ADR-T009 data-plane review (Codex) found a concurrency defect in the
ring prune watermark: a reader holding a superseded ring snapshot can
advance the media-time watermark AFTER a reconnect re-anchors to a fresh,
low media-time generation. The next forward publish then prunes the new
generation's own frames as "older than the stale watermark", dropping the
boundary frame the compositor is showing (invariant #2 / #9).

Deterministic two-thread reproduction: two barriers force the exact
interleaving publish(reconnect) -> stale latch_and_advance -> publish(fwd),
so the RED is reliable rather than a flaky stress loop. Fails on the
current media-time watermark (read_at(0) yields the later frame 101 instead
of the reconnected boundary frame 100); passes once the watermark is keyed
on the globally-monotonic publish sequence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(framestore): key ring prune watermark on publish seq for cross-generation safety

The prune watermark was a media timestamp, which is NOT monotonic across a
reconnect: after a backwards re-anchor to a fresh low-media-time generation,
a reader still holding the superseded high-media-time snapshot could
`fetch_max` the watermark to an old high value AFTER the re-anchor, and the
next forward publish then pruned the new generation's own frames (retain
`>= watermark`) — dropping the boundary frame the compositor is showing
(inv #2 / #9). The store()-reset on backwards could not close it: the stale
reader's advance lands after the reset.

Fix: key the watermark on the tile-global publish `seq` (from LatestSlot),
which is strictly increasing and NEVER reset across generations. A newer
generation's frames always carry strictly higher `seq`s than any frame an
old snapshot holds, so a stale cross-generation `fetch_max` can only leave
the watermark BELOW them and `retain(|e| e.seq >= watermark_seq)` keeps
them. This makes the prune generation-safe by construction — no generation
counter, no bit-packing, no reset — and removes the now-unnecessary
backwards watermark reset (sequence monotonicity subsumes it). RingEntry
carries its publish `seq`; the media-time `at` remains the selection key.

Read path stays wait-free/allocation-free (one relaxed `fetch_max`, now on
`seq`). Turns the RED cross-generation race test green; all existing
latch/prune/reconnect/decode-ahead tests pass unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(framestore): retitle reconnect test to the seq-monotonicity mechanism

The prune watermark is now keyed on the tile-global publish seq rather than
media time, so a reconnect is protected by sequence monotonicity, not by a
watermark reset. Retitle backwards_stamp_resets_the_watermark_* ->
backwards_stamp_reanchors_* and correct its comment/message to describe the
real mechanism (rule 27: no misleading test docs). Assertion comparisons are
byte-identical — only the name, comments, and message strings change; no
weakening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
aperim-agent added a commit that referenced this pull request Jul 10, 2026
…ct Origin allow-list (ADR-RT011, #50) (#250)

* docs(adr): ADR-RT011 — WS/SSE single-use ticket + Origin allow-list (SEC-01/13)

Implements the ADR-RT005 ticket decision (never built) and adds the SEC-13
CSWSH Origin allow-list. Records: POST /api/v1/ws/ticket mints a short-TTL,
single-use, principal-bound ticket; WS/SSE accept ?ticket= and drop the durable
?access_token= bearer (clean cut); a same-origin-or-allowlisted Origin gate runs
on both upgrades even in auth_disabled mode. Inv #10 preserved (bounded,
control-plane-only ticket store; wait-free-to-engine).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(control): RED — WS/SSE single-use ticket + Origin allow-list (SEC-01/13)

Failing tests first (ADR-RT011). ws_ticket.rs covers the WsTicketStore core
(scope-carry, single-use, TTL expiry, bounded drop-oldest) and the HTTP surface
(POST /ws/ticket requires auth + mints; the durable ?access_token= bearer is
rejected on WS+SSE; a minted ticket authenticates SSE exactly once).
realtime_origin.rs covers the Origin policy (same-origin/allow-list/null/absent)
and the gate on both upgrades — cross-origin refused even with auth disabled and
before auth with a valid bearer; same-origin/absent-origin/configured accepted.

RED evidence (against current HEAD): both test binaries fail to compile —
WsTicketStore, AllowedOrigins, WS_TICKET_TTL, WS_TICKET_CAPACITY, and
AppState::with_allowed_origins do not exist yet (E0432/E0599).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(control): GREEN — WS/SSE single-use ticket + Origin allow-list (SEC-01/13)

Implements ADR-RT011 (the never-built ADR-RT005 ticket decision) + the SEC-13
CSWSH Origin gate.

SEC-01 (CRITICAL, CWE-598): POST /api/v1/ws/ticket (Bearer/JWT header, Action::Read)
mints a bounded, single-use, >=240-bit (two uuid v4) WsTicketStore ticket carrying
the full Principal (all 3 scope axes) + its RT010 baseline; WS (/api/v1/ws) and SSE
(/api/v1/events) accept `?ticket=` and consume it atomically. The durable
`?access_token=` query is removed from WS, SSE, and /auth/status (header-only) — a
clean cut; native clients keep `Authorization: Bearer` on the upgrade. WsTicketStore
is control-plane-only, TTL-swept, drop-oldest (inv #10).

SEC-13 (CSWSH): AllowedOrigins enforced on BOTH upgrades, before auth and even in
auth_disabled mode. Absent Origin passes (native clients); a present Origin must be
configured-allowlisted or same-origin (Origin authority == Host); Origin:null denied.
Sourced from a new control.allowed_origins config field (default empty => same-origin
only), wired via AppState::with_allowed_origins in the CLI.

Preserves: send-only WS, Json-only REST, JWT alg:none refusal, the RT009 watermark,
and RT010 live-reauth (the ticket carries the mint-time baseline). OpenAPI spec
regenerated (POST /ws/ticket + WsTicketResponse). Test files carry only rustfmt
reflow + clippy-nit fixes (doc backticks, Duration checked_sub) — assertions unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(web): realtime client mints a single-use WS ticket, drops ?access_token= (SEC-01)

The SPA no longer smuggles the durable bearer through the WS URL (SEC-01/CWE-598).
New realtime/ticket.ts mints a short-lived single-use ticket via POST /api/v1/ws/ticket
(bearer as a header, never a URL) and exposes the credential-free base URL.
RealtimeConnection.#open is now async and mints a FRESH ticket before EVERY
(re)connect (a ticket is single-use), connecting with `?ticket=` — or a bare URL when
none can be minted (auth-disabled). The 4 realtime hooks (useEngineEvents/useHealth/
useSystemMetrics/useAudioLoudness) drop their `?access_token=` URL builders.
auth/status already used the Authorization header, so it is unchanged.

Tests: the useEngineEvents suites now stub the ticket fetch and await the async
connect; a new connection.test.ts asserts the URL carries `?ticket=` (never
access_token) and that each reconnect mints a distinct ticket. schema.ts regenerated
from the updated OpenAPI spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(control,config): RED — strict Origin parse for the CSWSH gate (SEC-13)

Six failing tests pinning the panel-BLOCKED Origin defects: the gate treats
the Origin as a raw string, not a real parse.

- config: `control.allowed_origins` wrongly accepts "://", "https://",
  "https://user@host", "https://host/path", "https://host?x", "garbage://host"
  (validator only rejects empty / missing "://").
- realtime: a configured `null`/bad-scheme/userinfo entry is stored as a raw
  lowercased string and matched, so it is allowable (defect #4); a path-bearing
  Origin is leniently reduced to its bare host and passes same-origin (defect #2);
  a present-but-non-UTF-8 Origin is folded into the absent case and admitted
  (defect #3).
- openapi: POST /api/v1/ws/ticket declares no `security` (defect #6).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(control,config): GREEN — strict RFC-6454 Origin parse for the CSWSH gate (SEC-13)

Introduce a strict `Origin` value type (`multiview-config`, no new deps): exactly
`http(s)://host[:port]` — a bracketed IPv6 literal, an optional port, and nothing
else (no path/query/fragment/userinfo; `null` and non-http(s) schemes are
unrepresentable). One parser backs config-load validation, allow-list
construction, and the request-header check, so they cannot diverge.

Closes the 6 panel-BLOCKED Origin defects:
1. config `control.allowed_origins` now `Origin::parse`s each entry, rejecting
   "://", "https://", userinfo, path, query, and non-http(s) at load.
2. `origin_authority` (raw `split("://")` + `split('/')`) is gone — `permits`
   parses the Origin, so a path-bearing value can no longer reduce to its bare
   host and pass same-origin.
3. `enforce_origin` distinguishes ABSENT (pass, native client) from
   PRESENT-but-unreadable (non-UTF-8 → deny fail-closed), no longer folding the
   latter into the absent case.
4. `AllowedOrigins` stores `Vec<Origin>`; `new` drops what does not parse, so
   `null`/malformed can never be stored (thus never matched) by ANY construction
   path. Matching + same-origin now compare parsed canonical origins.
5. ADR-RT011 §3 rewritten to describe the strict-parse, fail-closed behavior
   precisely (rule 27).
6. `POST /api/v1/ws/ticket` declares its Bearer auth in OpenAPI (a reusable
   `bearer_auth` HTTP scheme, referenced per-operation — no global default);
   spec regenerated.

The gate stays BEFORE auth and active under `auth_disabled` (unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(config): parse Origin verbatim — reject whitespace-padded origins (SEC-13)

Panel rev3 found the strict Origin parser still called `input.trim()`, so a
whitespace-padded value (`" https://ops.example "`, a trailing `\n`, a leading
tab, embedded space) was normalized and could validate + become allow-listed —
and a whitespace-bearing request Origin header would be accepted instead of
denied. A serialized Origin has NO surrounding/embedded whitespace; parse it
verbatim so padding fails closed (a padded scheme misses the http(s) match;
padding elsewhere is caught by parse_authority's existing whitespace rejection).
Adds `rejects_whitespace_padding` covering leading/trailing/newline/tab/embedded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(control): reject duplicate Origin headers on the CSWSH gate (SEC-13)

Panel rev4: enforce_origin read only headers.get(ORIGIN) (the first value), so a
request with MULTIPLE Origin headers could be admitted on a valid/allowed value
while another value is malformed/disallowed — a downstream component trusting a
different value is a CSWSH bypass. A browser sends exactly one Origin; more than
one is ambiguous, so fail closed via headers.get_all(ORIGIN) (a second value →
403). Adds duplicate_origin_headers_rejected (first value is same-origin and
would pass alone, so the duplicate itself is what denies) on WS + SSE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
aperim-agent added a commit that referenced this pull request Jul 11, 2026
Implement ADR-0041 Decision 8's "cap zlib decompressed size": a C=1
(compressed) SAP body is now inflated instead of rejected outright. The
compressed region is the whole post-auth body (payload-type + payload together,
RFC 2974 §3); it is inflated with miniz_oxide::decompress_to_vec_zlib_with_limit
capped at MAX_SDP_PAYLOAD, so the decompressed SDP is bounded EXACTLY as an
uncompressed one and a decompression bomb is rejected AT the cap (during inflate,
never allocated in full).

- SapError: replace CompressionUnsupported with DecompressFailed (corrupt/
  truncated zlib stream — dropped, SAP is unauthenticated) and
  DecompressedTooLarge { max } (bomb guard, cap exceeded).
- miniz_oxide: a direct dep (already in the lock via ureq). Pure Rust (miniz
  backend), no native deps, LGPL-clean — the crate stays unsafe_code = forbid.
  Always compiled: the packet codec is feature-independent.
- Docs (rule 27): packet + mod module docs, MAX_SDP_PAYLOAD, the C flag comment,
  and the ADR-0041 Status line now describe capped inflate, not rejection.

Supersedes the flagged deviation #2 (reject-C=1); the outright reject was the
deviation FROM the ADR, capped inflate conforms to it. Real-zlib interop
(VLC/Dante emit real zlib) proved by the RED vectors (110b0c15).

Gate: cargo test -p multiview-input {default, --features st2110} green (sap_packet
16 incl. 4 new C=1 tests); clippy {default, st2110} --all-targets -D warnings
clean; cargo deny check ok.

Refs: #96 #108, ADR-0041 (Decision 8), RFC 2974 §3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
aperim-agent added a commit that referenced this pull request Jul 11, 2026
Implement ADR-0041 Decision 8's "cap zlib decompressed size": a C=1
(compressed) SAP body is now inflated instead of rejected outright. The
compressed region is the whole post-auth body (payload-type + payload together,
RFC 2974 §3); it is inflated with miniz_oxide::decompress_to_vec_zlib_with_limit
capped at MAX_SDP_PAYLOAD, so the decompressed SDP is bounded EXACTLY as an
uncompressed one and a decompression bomb is rejected AT the cap (during inflate,
never allocated in full).

- SapError: replace CompressionUnsupported with DecompressFailed (corrupt/
  truncated zlib stream — dropped, SAP is unauthenticated) and
  DecompressedTooLarge { max } (bomb guard, cap exceeded).
- miniz_oxide: a direct dep (already in the lock via ureq). Pure Rust (miniz
  backend), no native deps, LGPL-clean — the crate stays unsafe_code = forbid.
  Always compiled: the packet codec is feature-independent.
- Docs (rule 27): packet + mod module docs, MAX_SDP_PAYLOAD, the C flag comment,
  and the ADR-0041 Status line now describe capped inflate, not rejection.

Supersedes the flagged deviation #2 (reject-C=1); the outright reject was the
deviation FROM the ADR, capped inflate conforms to it. Real-zlib interop
(VLC/Dante emit real zlib) proved by the RED vectors (110b0c15).

Gate: cargo test -p multiview-input {default, --features st2110} green (sap_packet
16 incl. 4 new C=1 tests); clippy {default, st2110} --all-targets -D warnings
clean; cargo deny check ok.

Refs: #96 #108, ADR-0041 (Decision 8), RFC 2974 §3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
aperim-agent added a commit that referenced this pull request Jul 11, 2026
…eature-gated) (#96) (#256)

* docs(adr): accept ADR-0033/T013/0041 for the AES67 software tier

Flip the three AES67/ST2110-30 audio I/O ADRs Proposed->Accepted as the
software-complete lanes land behind the off-by-default st2110/aes67 features:

- ADR-0033 (AES67 send+receive): the receive Aes67AudioProducer, the send
  Aes67Packetizer/RTP framing (marker=0 continuous), and the channel_bridge
  drop-oldest fix ship now; the PTP-anchored absolute send timestamp + rubato
  ppm resampler + real Dante interop stay a flagged hardware follow-on.
- ADR-T013 (RTP-audio rebase seam): the AES67 PCM path becomes the seam's
  second concrete consumer alongside WebRTC Opus.
- ADR-0041 (essence-agnostic SAP engine): the RFC 2974 codec + group set +
  bounded session table + announce schedule land with the corrected wire
  details; the general RFC 8866 sdp/ model stays ADR-0040-gated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(st2110): RED — channel_bridge must drop OLDEST, not newest (ADR-0033 §7)

The receive bridge between the async NIC recv task and the sync St2110Producer
dropped the NEWEST unit on a full bounded channel (mpsc try_send failure),
retaining stale backlog and discarding the freshest media — the ADR-0033 §7
defect. Convert the seam to a shared bounded ring (PacketSink + PacketRing,
the display-audio AudioFifo pattern) and pin genuine drop-oldest with a new
gated test. This commit keeps the ring's push at the OLD drop-newest behaviour
so the new test fails at runtime (survivors are the oldest {1,2}, not {3,4});
the GREEN commit flips it to pop_front drop-oldest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(st2110): channel_bridge genuine drop-oldest on full ring (ADR-0033 §7)

Flip PacketSink::push from the drop-newest scaffold to genuine drop-oldest:
a full ring evicts its OLDEST unit (pop_front) before appending the newest,
so a stalled reader retains the freshest media and the ring never grows past
capacity (invariant #10). The gated regression now passes (survivors are the
newest {3,4}). This is the corrected bounded-drop-oldest receive primitive the
AES67 audio socket path reuses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(st2110): RED — Aes67AudioProducer decode + T013-seam yield (ADR-0033 §3)

The audio analogue of St2110Producer: pull RTP packet units, depacketize with
V30Payload, convert big-endian L16/L24 -> canonical interleaved f32, and yield
an Aes67AudioFrame carrying the verbatim RTP timestamp + SSRC + a sequence-gap
discontinuity flag for the shared RtpAudioRebaser (ADR-T013). Carry the RTP
SSRC through St2110Packet so a genuine SSRC change re-anchors.

This commit lands the full producer plumbing + tests but stubs pcm_to_f32 to
silence (right shape, wrong values), so the two round-trip decode tests and the
producer sample-value test fail at runtime; the skip-malformed and gap-flag
plumbing tests already pass. The GREEN commit implements the real conversion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(st2110): AES67 L16/L24 -> f32 decode; complete Aes67AudioProducer (ADR-0033 §3)

Implement pcm_to_f32: the exact inverse-scale of the Aes67Packetizer encode
(L16 /32768, L24 /8388608), sign-extended big-endian samples to canonical
interleaved f32 in [-1.0, 1.0). The producer's decode round-trips against the
sibling egress codec within wire quantization; all RX tests pass (5/5 gated,
2/2 default). The single as-cast is confined to code_to_unit_f32 with the same
justified magnitude bound as st2110::packetize.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(audio): inv#1 gate — output pull tick count independent of RTP feed (ADR-0033 §3)

Pin the store-side half of the AES67 RX invariant #1: an output loop pulls a
byte-identical count and shape of frames from AudioStore whether the RTP-audio
producer publishes wild-ahead, far-below-head (reordered), negative-index, or
nothing at all. The store is sampled via publish_at + silence-fill read; the
media clock can never pace, stall, or short the output. (The rebaser-side half
— bounded forward-only indices under wild/wrapped/absent timestamps — is pinned
by multiview-input's rtp_audio_rebase.rs.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(aes67): RED — Aes67Sender wire round-trip through the v30 decoder (ADR-0033 §1)

The AES67 / ST 2110-30 program-audio sender (multiview-output): a bounded
drop-oldest sink fed by the program bus that packetizes to continuous marker=0
RTP (seq +1, ts +48 sample-groups, constant SSRC, dynamic PT), silence-filling
underruns. The pure send framing + Aes67Sender ship always-compiled; the UDP
send loop is behind the new off-by-default 'aes67' feature. Reuses the
display-audio AudioFifo for the bounded drop-oldest ring.

This commit stubs encode_pcm to silence (right byte length, wrong values), so
the wire round-trip (encode here -> multiview-input V30Payload decode) fails at
runtime while the four isolation tests pass: continuous marker=0 cadence,
silence-on-underrun, bounded drop-oldest / never-back-pressure (inv #10), and
feed-independent send cadence (inv #1). The GREEN commit implements the real
f32 -> big-endian L16/L24 encode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(aes67): f32 -> big-endian L16/L24 encode; sender round-trips (ADR-0033 §1)

Implement encode_pcm: L16 scales by 32767, L24 by 8388607 (2^23-1, never 2^23,
to keep full-scale positive from wrapping to the most-negative code), emitting
the high three octets of (v<<8) MSB-first — the exact layout multiview-input's
V30Payload sign-extends. The sender's output now round-trips through the input
decoder within wire quantization; all 5 send tests pass (round-trip + the four
inv #1/#10 isolation properties). Array-pattern destructuring keeps the encode
free of slice indexing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(aes67): UDP send transport + loopback & ffmpeg interop validation (ADR-0033)

Add the feature-gated Aes67UdpSender (bind + send_packet + an off-hot-path
serve timer loop draining the sender's bounded FIFO — inv #1/#10) and two
socket-real validations:

- multiview-output/tests/aes67_loopback.rs (feature aes67): drives the real
  serve loop over a [::1] UDP socket; decodes each datagram with multiview-
  input's pure RTP + L24 parsers (dev-dep) and asserts marker=0, +1 sequence,
  and L24 sample round-trip end-to-end.
- multiview-input/tests/aes67_ffmpeg_interop.rs (feature st2110, #[ignore]d):
  ffmpeg emits a real L24/48000/2 RTP sine to loopback; our Aes67AudioProducer
  decodes it as non-silent audio. Ran on demand: PASS (>=4 packets, peak>0.05),
  proving wire compatibility with an independent AES67 sender.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(aes67): fmt + clippy-clean the RX/TX test legs

rustfmt normalization + clippy fixes on the new test files: u32::try_from over
an 'as' cast (cast_possible_truncation), hoist a mid-fn const (items_after_
statements), digit-separate a hex SSRC (unreadable_literal), and fmt reordering.
No behavioral change; all RX/TX tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(sap): RED — RFC 2974 packet codec (parse/encode, auth-words, rejects)

Failing (does not compile: `multiview_input::sap` does not exist yet) golden
wire vectors + proptest round-trip for the SAP (Session Announcement Protocol,
RFC 2974) packet codec: IPv4/IPv6 origin, omitted vs explicit
`application/sdp` payload-type, deletion (T=1), the auth_len-as-32-bit-WORDS
regression (NOT VLC's byte bug), and the reject set (E=1 encrypted, C=1
compressed, hash 0, short buffer, wrong version). Per ADR-0041 §8 + brief §3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(sap): RFC 2974 packet codec + SapError + module wiring (ADR-0041)

The essence-agnostic SAP (Session Announcement Protocol, RFC 2974) packet
codec: pure byte-slice <-> typed `SapPacket` parse/encode, honouring the
adversarially-corrected wire details (ADR-0041 §8): version is the top 3 bits,
`auth_len` counts 32-bit WORDS (skip words*4, not VLC's byte bug), the
message-id hash is a `NonZeroU16` (never 0 — rejected on parse, unrepresentable
on encode), `E=1` and `C=1` are rejected (no SAPv2 crypto; `flate2` is not a
dep so there is no bounded inflate), the payload-type is omitted when the body
begins `v=0`, and the opaque SDP payload is hard-capped at 64 KiB. All
arithmetic checked; no panics, no indexing, no `as` casts — mirrors the
`st2110::rtp` parser style. Adds the `SapError` thiserror enum + the module doc
and wires `pub mod sap;` into `multiview-input`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(sap): RED — multicast group set + announce scope selector

Failing (does not compile: `sap::groups` does not exist) tests for the SAP
group set + scope selector (RFC 2974 §3 / RFC 2365; ADR-0041 §7, brief §4):
SAP_PORT=9875, SAP_TTL=255, the receive set = the four IPv4 groups (incl. the
AES67/Dante 239.255.255.255, excl. obsolete SAPv0 224.2.127.255) + the IPv6
SAP group, and announce_group_for() picking the SAP group from the media
address scope for both IPv4 (239.255/16, 239.192/14, 224.0.0/24, else global)
and IPv6 (the FF0X scope nibble). SapGroup and MediaGroup are distinct types.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(sap): multicast group set + announce scope selector (ADR-0041 §7)

The listener joins the full RFC 2974 §3 group set — the four IPv4 SAP groups
(global 224.2.127.254, org-local 239.195.255.255, local/AES67-Dante
239.255.255.255, link-local 224.0.0.255) plus the IPv6 site-local group
FF05::2:7FFE — so every AES67/Dante session is discoverable; the obsolete SAPv0
group 224.2.127.255 is never joined. The announcer picks its SAP group from the
RFC 2365 admin scope of the media address (never a TTL), the #1 'VLC shows
nothing' footgun. SapGroup and MediaGroup are distinct newtypes so the
signalling group can never be confused with the SDP c= media group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(sap): RED — discovered-session table (lifecycle + bounded + hijack guard)

RED test for the bounded, drop-oldest, untrusted discovered-session table
(ADR-0041 §3/§4/§8): records announcements keyed on (msg-id hash, origin),
refreshes + learns the observed period on re-announcement, IGNORES inbound T=1
deletions against tracked sessions (spoof/hijack guard — expire by timeout
only), bounds one origin's share, drops the oldest past global capacity (never
grows, inv #10), and purges sessions unseen for max(10*period, 1h). A proptest
pins inventory.len() <= capacity for any observation sequence.

Fails to compile: `unresolved import multiview_input::sap::session`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(sap): bounded drop-oldest discovered-session table (ADR-0041 §3/§4/§8)

Wait-free ArcSwap (RCU) table of untrusted discovered sessions keyed on
(msg-id hash, origin), mirroring caption_store's clone-on-write publish so a
reader never blocks a writer and nothing here can pace or back-pressure the
engine (inv #1/#10). observe() records/refreshes announcements (learning the
observed period), and IGNORES inbound T=1 deletions against tracked sessions —
a spoofed deletion must never withdraw one (ADR-0041 §8); sessions expire only
via purge() at max(10*period, 1h). New sessions are bounded per-origin then
globally, always drop-oldest-by-last-seen, never growing (bounded memory).
Discovered sessions are untrusted hints the table only records — confirm-to-bind
lives elsewhere. 10 tests + a bounded-memory proptest pass.

Also clippy-cleans the groups scope selector (RangeInclusive::contains) and two
doc-token backticks from 32ec28ea.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(sap): RED — announce schedule (30s floor, +/-1/3 jitter) + builders

RED test for the pure announce schedule (ADR-0041 §5, brief §3): a >=30s base
cadence with +/-1/3 jitter (offset = rand(interval*2/3) - interval/3), the
T=0/T=1 announcement + deletion builders carrying the application/sdp
payload-type, and a stable non-zero per-output hash. A proptest pins the
jittered delay to [2/3*base, 4/3*base] for any base and sample.

Fails to compile: `unresolved import multiview_input::sap::announce`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(sap): announce schedule (30s floor, +/-1/3 jitter) + packet builders (ADR-0041 §5)

Pure announce logic, no sockets. AnnounceSchedule floors the base cadence to
the 30s interop default and yields a +/-1/3-jittered next_delay from an
externally-supplied RNG sample (offset = width*sample/2^64, integer-nanosecond
math — no float/lossy cast), landing in [2/3*base, 4/3*base). announcement()/
deletion() build the T=0/T=1 SapPackets carrying the explicit application/sdp
payload-type; stable_hash() folds an SDP body to a stable non-zero 16-bit hash
(same SDP -> same hash for dedup; changed SDP -> different for modification
detection; 0 never produced). Off the output clock — the announce timer is
independent of the per-tick loop (inv #1). 6 tests + a jitter-bound proptest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(sap): RED — socket transport loopback validation (st2110)

RED test for the supervised UDP listener + announcer (ADR-0041 §3/§5): a
loopback round-trip (announce -> receive -> parse -> fold into the wait-free
table), a malformed/empty datagram skipped without killing the receive loop, a
spoofed inbound deletion that never withdraws a tracked session (hijack guard
end-to-end), an IPv6-first [::] bind (ADR-0042), and the announce run loop
emitting its first cycle. Gated behind the st2110 feature.

Fails to compile: `unresolved import multiview_input::sap::transport`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(sap): supervised UDP listener + announcer, loopback-validated (ADR-0041 §3/§5)

The st2110-gated socket transport. SapListener binds an IPv6-first UDP socket,
joins the SAP group set (live multicast path, per address family), and runs a
receive loop that folds each announcement into the wait-free bounded session
table — a malformed/rejected datagram is skipped so the loop never dies on bad
input, and a spoofed inbound deletion never withdraws a tracked session
(hijack guard end-to-end). SapAnnouncer builds/sends the T=0/T=1 packets on an
INDEPENDENT jittered timer (never paces output, inv #1), best-effort per send,
with courtesy deletions on teardown. No unbounded queue anywhere; nothing can
back-pressure the engine (inv #10). 5 loopback tests: announce round-trip,
malformed-skip, wire-level hijack guard, [::] IPv6 bind, run-loop first cycle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(st2110): RED — RtpReceiver family-agnostic join_multicast (ADR-0042)

RED test for the IPv6-first multicast join on the ST 2110 / AES67 receive
socket: a family-agnostic rx.join_multicast(group) that dispatches on the
address family (so pipeline.rs wiring is one clean call regardless of an SDP
c=IN IP4 / c=IN IP6 line), plus an explicit join_multicast_v6. Real joins on
the multicast-capable eth0; gated behind st2110.

Fails to compile: no method `join_multicast` / `join_multicast_v6` on RtpReceiver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(st2110): family-agnostic RtpReceiver::join_multicast + join_multicast_v6 (ADR-0042)

IPv6-first multicast join on the ST 2110 / AES67 receive socket: join_multicast_v6
(interface index, 0 = default) and a dual-stack join_multicast(IpAddr) that
dispatches on the address family — IPv4 via INADDR_ANY, IPv6 via interface 0 —
so the pipeline.rs RX wiring is one clean rx.join_multicast(group) call whether
the SDP c= line is IP4 or IP6. Real joins on eth0; 3 tests under st2110.
(Also simplifies join_multicast_v4's signature to the now-imported Ipv4Addr.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style(sap): rustfmt normalize session + announce/transport tests

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style(audio): allow cast_possible_wrap in the inv#1 store test (small loop index)

The LANE-RX inv#1 gate test casts a small non-negative loop counter usize->i64
for publish_at frame indices; that never wraps. The file already allows the
sibling cast lints with the same justification — add cast_possible_wrap so the
-D warnings gate is clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(sap): correct mod-level docs to match the implementation (rule 27)

The transport folds datagrams directly into the wait-free session table — there
is no separate 'receive ring'; the session table IS the bounded drop-oldest
structure. The table bound is a global capacity + a per-origin cap (not a
time-windowed rate limiter) — describe it precisely.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(sap): RED — C=1 zlib body inflated under a hard cap (ADR-0041 §3/D8)

Replace the outright-reject contract (SapError::CompressionUnsupported) with
the capped-inflate contract mandated by ADR-0041 Decision 8 ("cap zlib
decompressed size"): a valid C=1 body inflates to its SDP, an inner
payload-type field (RFC 2974 §3 compresses it WITH the payload) is recovered,
a >MAX_SDP_PAYLOAD decompression bomb is rejected AT the cap, and a corrupt
zlib stream is dropped — never a panic, never an unbounded allocation.

Vectors are produced by REAL zlib (Python zlib.compress), not the decoder's own
library, so they prove interop with the compressed announcements VLC/Dante emit
(rule 26), verified byte-for-byte + inflate-size before embedding.

RED: the SapError::DecompressedTooLarge / DecompressFailed variants do not exist
yet (E0599), so the test crate does not compile.

Refs: #96 #108, ADR-0041 (Decision 8), RFC 2974 §3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(sap): inflate C=1 zlib bodies under a hard cap (ADR-0041 D8)

Implement ADR-0041 Decision 8's "cap zlib decompressed size": a C=1
(compressed) SAP body is now inflated instead of rejected outright. The
compressed region is the whole post-auth body (payload-type + payload together,
RFC 2974 §3); it is inflated with miniz_oxide::decompress_to_vec_zlib_with_limit
capped at MAX_SDP_PAYLOAD, so the decompressed SDP is bounded EXACTLY as an
uncompressed one and a decompression bomb is rejected AT the cap (during inflate,
never allocated in full).

- SapError: replace CompressionUnsupported with DecompressFailed (corrupt/
  truncated zlib stream — dropped, SAP is unauthenticated) and
  DecompressedTooLarge { max } (bomb guard, cap exceeded).
- miniz_oxide: a direct dep (already in the lock via ureq). Pure Rust (miniz
  backend), no native deps, LGPL-clean — the crate stays unsafe_code = forbid.
  Always compiled: the packet codec is feature-independent.
- Docs (rule 27): packet + mod module docs, MAX_SDP_PAYLOAD, the C flag comment,
  and the ADR-0041 Status line now describe capped inflate, not rejection.

Supersedes the flagged deviation #2 (reject-C=1); the outright reject was the
deviation FROM the ADR, capped inflate conforms to it. Real-zlib interop
(VLC/Dante emit real zlib) proved by the RED vectors (110b0c15).

Gate: cargo test -p multiview-input {default, --features st2110} green (sap_packet
16 incl. 4 new C=1 tests); clippy {default, st2110} --all-targets -D warnings
clean; cargo deny check ok.

Refs: #96 #108, ADR-0041 (Decision 8), RFC 2974 §3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(st2110): RED — next_audio must be bounded work under a malformed flood (F1)

A malformed-packet flood makes one next_audio() call loop until the
source empties, so a single sample can do unbounded work and delay the
output clock (panel F1, inv #1). The RED asserts a bounded per-call poll
budget; against the current unbounded loop it polls 5001 of a 5000-packet
flood, blowing past the ceiling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(st2110): bound next_audio to a per-poll packet budget (F1, inv #1)

next_audio() skipped malformed units in an unbounded loop until the
source drained, so one sample could do unbounded work and delay the
output clock under a malformed-packet flood (panel F1, CRITICAL).

Cap the skip loop at Aes67AudioProducer::MAX_PACKETS_PER_POLL (64) units
per call; budget exhausted with nothing valid yields Ok(None) and the
caller re-polls next tick. A flood is drained at the budget rate per tick
rather than all at once — a sample is now bounded work whether it runs on
the output-sample side or a shared ingest thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(st2110): RED — RX packet ring must never block on a held lock (F2)

PacketSink::push and ChannelPacketSource::poll_packet take a blocking
std::sync::Mutex::lock(); the sync ingest consumer can block behind the
tokio receive task holding the lock (panel F2, inv #10 shed-never-block).

The RED holds the ring lock and asserts a concurrent push/poll answers
within a deadline. Against the blocking .lock() both time out (2s) — the
worker waits for the guard — so both fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(st2110): try_lock the RX packet ring, never a blocking lock (F2, inv #10)

PacketSink::push and ChannelPacketSource::poll_packet took a blocking
std::sync::Mutex::lock(), so the sync ingest consumer could block behind
the tokio receive task holding the ring (panel F2). Switch push, poll_packet,
is_closed and dropped to try_lock: on contention (or a poisoned lock) the
data path sheds — push drops the unit (bounded/never-grow preserved),
poll_packet/dropped report nothing-this-tick, is_closed stays 'open'. The
critical section is a few O(1) VecDeque ops so genuine contention is
nanosecond-scale. Drop keeps a blocking lock (teardown, sets consumer_gone
reliably; cannot deadlock or back-pressure).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(sap): RED — datagram rate-limit before the expensive fold (F4)

Every accepted SAP datagram forces an O(n) RCU clone + publish in the
session table; a spoofed-origin flood at line rate bypasses the per-origin
cap and can starve the shared control-plane runtime (panel F4, inv #10).

RED adds a pure SapRateLimiter (deterministic fixed-window tests) and a
deterministic listener-wiring flood test asserting a 200-datagram burst
folds at most the rate-limit burst. Both fail to compile — the limiter
and the with_rate_limit builder do not exist yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sap): rate-limit datagrams before the expensive fold (F4, inv #10)

Every accepted SAP datagram cloned the whole session snapshot + SDP
buffers (O(n) RCU) and purged per-datagram; a spoofed-origin flood at
line rate bypassed the per-origin cap and could force that expensive work
per datagram, starving the shared control-plane runtime (panel F4).

Add a pure fixed-window SapRateLimiter (integer-only, deterministic) and
gate the parse+fold on it in SapListener::run: at most accept_burst folds
per accept_window (default 128/s, tunable via with_rate_limit); excess
datagrams are dropped after only the cheap recv, before the O(n) clone.
Purge is decoupled to a PURGE_INTERVAL cadence (1s) rather than
per-datagram. Resolves deviation #1 — ADR-0041 Decision 3's 'per-source +
global rate limits' is now implemented (the caps bound memory, the
limiter bounds the expensive-fold rate); ADR-0041 Status updated (rule 27).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(aes67): RED — split TX sender into a concurrent handle + serve loop (F3)

Aes67Sender::push and next_packet both take &mut self, so the engine bake
push and the send/serve loop cannot run concurrently without a mutex —
the off-hot-path ProgramBus->sender isolation is not provable with this
ownership model (panel F3, inv #10).

RED adds: an integration test that a block pushed on a producer HANDLE is
drained by the serve side (shared FIFO), and two forced-contention unit
tests that a held FIFO lock blocks neither the bake push nor the serve
next_packet. All fail to compile — the handle, the shared Arc<Mutex> FIFO,
and Aes67Sender::handle() do not exist yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(aes67): split TX sender into a concurrent handle + serve loop (F3, inv #10)

Aes67Sender::push and next_packet both took &mut self, so the engine
bake push and the send/serve loop could not run concurrently without a
mutex — the off-hot-path ProgramBus->sender isolation was not provable
(panel F3).

Split the send FIFO behind a shared Arc<Mutex<AudioFifo>>:
- Aes67SenderHandle (producer half, Clone): push(&self) — the handoff
  primitive the pipeline (#103) wires bake->sender with; the engine bake
  side holds it and is never back-pressured.
- Aes67Sender (serve half): next_packet(&mut self) drains the shared FIFO;
  handle() mints producer handles.
Both reach the FIFO via try_lock, never a blocking lock: on contention the
push sheds and the drain silence-fills, so neither half can block the other
(inv #1 continuous output / #10 shed-never-block). Existing tests migrated
to the handle API (push moved off Aes67Sender).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(aes67): RED — fallible TX sender construction rejects bad config (F5)

Aes67Sender::new clamped channels/frames_per_packet to >=1 with no upper
bound on channel count, ptime, or capacity, so extreme config could pre-
allocate a huge FIFO, emit an oversized (IP-fragmenting) UDP packet, or
overflow the RTP timestamp increment toward u32::MAX (panel F5).

RED adds rejects_out_of_range_config asserting bad channel count / ptime /
payload-size / capacity return typed Aes67ConfigError variants. Fails to
compile — the fallible constructor, the error type, and the bound consts
do not exist yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(aes67): fallible TX sender construction with config bounds (F5)

Aes67Sender::new clamped channels/frames_per_packet to >=1 with no upper
bound, so extreme config could pre-allocate a huge FIFO, emit an oversized
(IP-fragmenting) UDP packet, or push frames_per_packet toward u32::MAX and
overflow the RTP timestamp increment (panel F5).

Make new fallible (-> Result<Self, Aes67ConfigError>), fail-closed on:
channel count outside 1..=MAX_CHANNELS(64), ptime outside
1..=MAX_FRAMES_PER_PACKET(4800), capacity above MAX_CAPACITY_FRAMES(48000),
and a channels x ptime x depth payload above MAX_PACKET_PAYLOAD_BYTES(1440,
one standard MTU minus IPv6/UDP/RTP — AES67 packets must not IP-fragment).
The bounded ptime keeps the timestamp increment inside u32. Call sites
updated to construct-then-expect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(st2110): RED — join_multicast must thread the IPv6 interface index (F6)

RtpReceiver::join_multicast and SapListener::join_receive_groups hardcode
the IPv6 multicast interface index to 0; IPv6 multicast is interface-scoped
so index 0 is not portable and commonly fails for link/site-local groups
(panel F6). RED threads a bogus interface index through join_multicast and
asserts the OS rejects it — proving the index is supplied, not hardcoded.
Fails to compile: MulticastInterface and the 2-arg join_multicast do not
exist yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(st2110,sap): plumb the IPv6 multicast interface index (F6)

RtpReceiver::join_multicast and SapListener::join_receive_groups hardcoded
the IPv6 multicast interface index to 0; IPv6 multicast is interface-scoped
(RFC 4291 scope-id), so index 0 is not portable and commonly fails for
link/site-local groups (panel F6).

Add MulticastInterface (Unspecified | Index(u32)); join_multicast now takes
it and threads the index to join_multicast_v6 (IPv4 stays INADDR_ANY).
SapListener gains with_interface for the same. A test proves the index is
supplied — a bogus index reaches the OS and fails the join, where the old
hardcoded 0 would have spuriously succeeded. Full live validation on a real
IPv6 multicast network is hardware-gated (rule 26); the interface-index
selection is what this plumbs (config/SDP source lands with #103 wiring).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style(aes67): rustfmt the sender split + fallible-construction changes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(sap): verify the C=1 inflate cap with a bomb dwarfing it (ADR-0041 D8)

The C=1 (zlib) SAP inflate is capped at MAX_SDP_PAYLOAD (64 KiB) via
miniz_oxide's decompress_to_vec_zlib_with_limit, so an unauthenticated
compressed announcement can never become a decompression bomb (ADR-0041
Decision 8). This converts that bomb-safety from *asserted* (the API
contract) to *verified* (rule 23/25) with a negative test that exercises
the cap on an adversarial input dwarfing it:

- rejects_a_bomb_whose_decompressed_size_dwarfs_the_cap: a valid zlib
  stream whose decompressed form is 8 MiB (128x the cap) is rejected with
  DecompressedTooLarge; the datagram itself is a few KiB — the definition
  of a decompression bomb (tiny input, unbounded expansion).
- inflates_a_body_exactly_at_the_cap_and_rejects_one_byte_over: the cap is
  an EXACT inclusive threshold — a body inflating to exactly the cap is
  accepted (legit near-cap Dante/VLC announcements still interop), cap+1
  is rejected.

RED evidence (rule 25 — non-tautological, and the during-inflate cap is
load-bearing for bounded PEAK memory): with the cap bypassed (uncapped
decompress_to_vec_zlib) both tests FAIL — the 8 MiB bomb is materialized
in full and only caught after the fact:
  left:  Err(PayloadTooLarge { size: 8388607, max: 65536 })
  right: Err(DecompressedTooLarge { max: 65536 })
The size: 8388607 in the bypassed error is the direct proof: without the
cap ~8 MB is allocated before rejection; with it, allocation halts at
65536. Restoring the cap -> both GREEN.

Complements the existing rejects_compression_bomb_over_the_cap (a real
external-zlib 195 KiB bomb). miniz_oxide added as a dev-dependency (same
crate/version already a normal dep, deny-neutral) to generate the
adversarial bomb at test time instead of embedding a multi-KiB magic
const. No production change — packet.rs is untouched.

Supersedes panel DEVIATION-2 ("reject C=1"): that verdict reviewed stale
pre-inflate code; ADR-0041 D8 mandates cap-and-inflate, proven safe here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(sap): RED — stable_hash must match pinned FNV-1a, not DefaultHasher (P2-F4)

Panel finding (HIGH): sap::announce::stable_hash uses DefaultHasher, whose
output Rust explicitly does NOT guarantee stable across toolchain versions. A
version bump would change the message-id hash for unchanged SDP across a
restart, so a receiver could not correlate an announce/delete pair (RFC 2974
§8) and would carry a duplicate session until timeout.

The golden-vector test pins the FNV-1a-of-SDP contract; it fails against the
current DefaultHasher (empty SDP: got 55124, want the pinned 61457).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sap): pin stable_hash to FNV-1a, not DefaultHasher (P2-F4)

DefaultHasher's output is explicitly not a stable cross-version contract, so a
toolchain bump could change the message-id hash for identical SDP — a restarted
announcer's unchanged sessions would then look new to receivers (duplicate
sessions until timeout; RFC 2974 §8). Replace it with FNV-1a/64 (a small,
fully-defined algorithm), folded to 16 bits and forced non-zero exactly as
before, so the mapping is stable across toolchains, targets, and restarts.

Pinned by golden vectors added in the preceding RED commit; the existing
determinism + content-sensitivity tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(st2110): RED — SSRC change must reset the sequence watermark (P2-F5)

Panel finding (MED): Aes67AudioProducer tracks last_sequence with no SSRC
association. When the SSRC changes to a stream whose sequence base RFC 1982
arithmetic reads as 'before' the old watermark, every new-stream packet is
judged stale and never updates the watermark — so a genuine gap on the new
stream (60001 -> 60003) is silently NOT flagged as a discontinuity.

The test drives A@seq100 -> B@seq60000/60001/60003 and asserts B's real gap is
flagged; it fails today (the stuck watermark misses it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(st2110): scope the RX sequence watermark per-SSRC (P2-F5)

RTP sequence numbers are only comparable within one synchronization source.
Aes67AudioProducer tracked a single last_sequence with no SSRC association, so
after an SSRC change to a stream whose sequence base serial arithmetic reads as
'before' the old watermark, every new-stream packet was judged stale, the
watermark never advanced, and real gaps on the new stream were missed.

Track last_ssrc alongside the watermark; on an SSRC change, reset the watermark
to the new packet's sequence and report no gap (the first packet of a stream
anchors — the shared RtpAudioRebaser re-anchors on the SSRC change itself).
Existing anchor + gap tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(sap): RED — concurrent observe() must not lose sessions (P2-F2)

Panel finding (HIGH): SapSessionTable::observe is a load -> clone -> mutate ->
store read-modify-write. A with_table-shared table is written concurrently (a
dual-stack v4 + v6 listener; the purge tick). ArcSwap makes the STORE atomic,
not the whole RMW, so two writers clone the same snapshot and the second store
clobbers the first — a silently lost session.

Eight threads inserting 250 distinct sessions each (2000 total, caps well above
so nothing is evicted) currently keep only ~378 — 1622 clobbered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sap): serialize SapSessionTable writers so observe() cannot lose sessions (P2-F2)

ArcSwap publishes atomically but does not make observe()'s load -> clone ->
mutate -> store atomic as a whole, so concurrent writers (a dual-stack v4 + v6
listener sharing one table; the purge tick) could each clone the same snapshot
and the second store would clobber the first — a silently lost session.

Add a write-serializing Mutex<()> taken by observe (announcement path) and
purge; readers (inventory/len/is_empty) stay wait-free on the ArcSwap and never
take it. Every writer is control/discovery-plane, so serializing them cannot
pace or back-pressure the engine (inv #1/#10). A poisoned lock still yields the
guard (the published snapshot is always consistent).

The 8-thread/2000-session race test now keeps all 2000; existing bounded-
capacity, per-origin, refresh, and purge tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(sap): RED — purge must run on its own timer, not behind recv (P2-F3)

Panel finding (HIGH): SapListener::run purges expired sessions only AFTER
recv_from returns. If announcements stop, the task parks in recv_from forever
and expired sessions are never reaped. Under paused time, seeding one session
and advancing past the 1 h purge floor with no datagram sent leaves it tracked
(the purge is gated behind the parked receive).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sap): purge on an independent timer, not behind recv_from (P2-F3)

SapListener::run purged only after recv_from returned, so a parked receive
(announcements stopped) blocked expiry forever. Select the receive against an
independent tokio::time::interval(PURGE_INTERVAL) tick, so expired sessions are
reaped on their own cadence regardless of datagram arrival. Purge still runs off
the per-datagram path, so a flood cannot amplify the O(n) scan (panel F4 intact).

Source the purge 'now' + cadence from tokio::time::Instant (real monotonic time
in production; drivable under tokio::time pause) so the behaviour is testable
without a 1 h wall-clock wait.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(sap): RED — announcer must select the IPv6 multicast egress interface (P2-F9)

Panel finding (MED): SapAnnouncer::send_to has no IPv6 scope/interface/hop-limit
for a scoped-multicast announce dest, so a multi-homed or link-local IPv6
deployment cannot select the egress interface (IPV6_MULTICAST_IF). Mirroring the
F6 RX-join proof, the test threads a bogus interface index and asserts the OS
rejects it — currently the with_interface/configure_multicast_egress egress API
does not exist, so it fails to compile (the plumbing is absent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sap): select the IPv6 multicast egress interface for announcements (P2-F9)

SapAnnouncer only set the IPv4 multicast TTL; an IPv6 scoped/link-local announce
dest had no egress interface (IPV6_MULTICAST_IF) or hop-limit, so a multi-homed
or link-local deployment could not select the interface. Add with_interface +
configure_multicast_egress, reusing the F6 MulticastInterface and reaching the
setsockopts via a socket2 SockRef over the bound socket (tokio's UdpSocket does
not expose them). socket2 is an optional dep enabled by the st2110 feature
(already in the lock, MIT/Apache-2.0, no FFI — crate stays unsafe_code=forbid).

The interface index is sourced from config/SDP scope and wired by the pipeline
(#103); until then it defaults to the OS default, never a hardcoded 0. Proven
hardware-free (rule 26) by the bogus-index-errors test: a nonexistent index
reaches the OS and fails, so the index is genuinely plumbed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(aes67): RED — derive the send cadence from the media clock (P2-F1)

Panel finding (HIGH): Aes67UdpSender::serve accepts an arbitrary ptime unrelated
to the sender's sample_rate + frames_per_packet, so a stream advertising e.g.
48 kHz (RTP timestamp +48/packet) can be emitted on a 200 µs timer — 5x too
fast — a deterministic clock drift that fails the receiver buffer.

The test pins packet_duration() = frames_per_packet / sample_rate (1 ms @ 48@48k,
2 ms @ 96@48k, 0.5 ms @ 48@96k) and fail-closed rejection of a 0 / out-of-range
sample rate. The sample_rate ctor arg, packet_duration(), and the SampleRate
config error do not exist yet, so it fails to compile (the derivation is absent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(aes67): derive the send cadence from the media clock (P2-F1)

Aes67UdpSender::serve accepted an arbitrary ptime unrelated to the sender's
sample_rate + frames_per_packet, so a 48 kHz stream (RTP timestamp +48/packet)
could be driven on any timer — a deterministic clock drift that fails the
receiver buffer (the loopback test literally passed 200 µs for a 1 ms stream).

Aes67Sender now takes a validated sample_rate (the RTP media clock) and exposes
packet_duration() = frames_per_packet / sample_rate in exact integer nanoseconds
(never float fps — inv #3). serve() derives its timer from that, dropping the
caller-supplied ptime, so the wire cadence always matches the advertised clock.
The sample rate is fail-closed to MIN_SAMPLE_RATE_HZ..=MAX_SAMPLE_RATE_HZ (folds
into the F5 Aes67ConfigError). Call sites updated to pass the rate; the loopback
test now runs at the derived 1 ms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(aes67): RED — reject an out-of-range RTP payload type (P2-F7)

Panel finding (MED): Aes67Sender::new accepts any u8 payload_type and
build_rtp_header masks it with & 0x7f, so a PT >127 is silently truncated (200
-> 72) and the transmitted PT diverges from the advertised SDP. The test expects
fail-closed rejection of 128/200 while 96/127 still construct; the PayloadType
config error does not exist yet, so it fails to compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(aes67): validate RTP payload type 0..=127 fail-closed at config (P2-F7)

The RTP payload type is a 7-bit field; a value >127 was silently truncated with
& 0x7f in build_rtp_header, so the transmitted PT diverged from the advertised
SDP (200 -> 72). Aes67Sender::new now rejects a payload_type above
MAX_RTP_PAYLOAD_TYPE (127) via Aes67ConfigError::PayloadType (folded into the F5
fallible ctor), so the stored type always matches what is sent. The & 0x7f mask
is retained and documented as defensive-in-depth for direct build_rtp_header
callers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(aes67): RED — the send path must reuse its datagram buffer (P2-F6)

Panel finding (MED, rule 22): next_packet allocates >=2 Vecs per tick (the
encode_pcm payload + the datagram) on the continuous send path, violating the
no-per-packet-alloc data-plane rule. This adds next_packet_into(out) — the
reused-buffer drain the serve loop will use — with a placeholder body that still
reallocates (*out = self.next_packet()), and a test asserting the buffer's
allocation (pointer + capacity) is stable across 1000 packets. It fails today:
the pointer moves every packet (0x..770 vs 0x..630).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(aes67): reuse one datagram buffer on the send path — no per-packet alloc (P2-F6)

next_packet allocated >=2 Vecs per tick (encode_pcm payload + the datagram),
violating the no-per-packet-alloc data-plane rule (rule 22). next_packet_into now
builds the packet in place into a caller-owned buffer: clear (keep the
allocation), reserve the exact size once, then write header + PCM via the new
encode_pcm_append (the allocation-free core of encode_pcm). serve() owns one
datagram buffer, warmed on the first tick and reused forever. next_packet is now
just next_packet_into over a fresh Vec, so the wire bytes are unchanged (the
decoder round-trip still passes).

The RED commit's pointer-stability test now holds across 1000 packets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(aes67): RED — TX sender must select the IPv6 multicast egress interface (P2-F8)

Panel finding (MED): Aes67UdpSender only configures the IPv4 multicast TTL; an
IPv6 scoped/link-local AES67 flow has no egress interface (IPV6_MULTICAST_IF) or
hop-limit, so a multi-homed / link-local deployment cannot select the interface.
Mirroring the input F6/F9 RX proof, the test threads a bogus interface index and
asserts the OS rejects it — the with_interface/configure_multicast_egress egress
API (and a local MulticastInterface) does not exist yet, so it fails to compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(aes67): select the IPv6 multicast egress interface for the TX sender (P2-F8)

Aes67UdpSender only set the IPv4 multicast TTL; an IPv6 scoped/link-local flow
had no egress interface (IPV6_MULTICAST_IF) or hop-limit, so a multi-homed /
link-local deployment could not select the interface. Add a local
MulticastInterface (mirrors the input crate's — the two are intentionally
decoupled), with_interface, and configure_multicast_egress, reaching the
setsockopts via a socket2 SockRef over the bound socket (tokio's UdpSocket does
not expose them). socket2 is an optional dep enabled by the aes67 feature
(already in the lock, MIT/Apache-2.0, no FFI — crate stays unsafe_code=forbid).

The interface index is sourced from config/SDP scope and wired by the pipeline
(#103); until then it defaults to the OS default, never a hardcoded 0. Proven
hardware-free (rule 26) by the bogus-index-errors test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style(aes67): use Duration::from_millis in the F1 cadence test (clippy)

clippy::duration_suboptimal_units: from_micros(1_000)/from_micros(2_000) read
better as from_millis(1)/from_millis(2). Test-only, assertions unchanged (the
500 µs case stays as-is).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style: rustfmt the AES67 re-panel changes

rustfmt normalization of the panel-2 test call sites (the widened
Aes67Sender::new 7-arg calls wrap across lines) and the added RED tests. No
behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(aes67): RED — send cadence must use cumulative absolute deadlines, not a repeated truncated interval (#153/T1)

A non-48k-dividing rate (48 frames @ 44.1 kHz) truncates packet_duration to a
single Duration; repeating it drifts ~0.374 ns/packet, unbounded over a 24/7
run. Pin the fix: each packet's deadline is the exact floor of the cumulative
index (n×frames/sample_rate), so its error stays < 1 ns forever while a repeated
interval's error grows with n.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(aes67): drive the send loop from cumulative absolute deadlines, not a repeated truncated interval (#153/T1)

`Aes67Sender::packet_deadline_offset(n)` returns the exact-floored absolute
offset of packet n from the send-loop start (n×frames_per_packet/sample_rate, in
u128). `Aes67UdpSender::serve` now `sleep_until`s that per-packet deadline
instead of repeating `interval(packet_duration())`, so the wire cadence stays
locked to the RTP media clock with sub-nanosecond error forever. Repeating one
floored `packet_duration` drifts without bound at any rate frames_per_packet
does not evenly divide (e.g. 48 @ 44.1 kHz) — walking the receiver buffer
(inv #1/#3). packet_duration retained as the nominal ptime; its doc no longer
claims serve() repeats it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(sap): RED — missed purge ticks must skip to one reap, not burst N (#154/I1)

Adds a purge-scan counter to SapSessionTable (reap-cadence observability) and a
paused-time test: run the listener with no datagrams, jump the clock past 5
purge intervals in one step, and assert exactly ONE reap scan ran. The default
tokio::interval MissedTickBehavior is Burst, so it currently fires 5 redundant
O(n) scans back-to-back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sap): skip missed purge ticks instead of bursting redundant reaps (#154/I1)

SapListener::run's purge interval now sets MissedTickBehavior::Skip: a stall
that misses several purge periods reaps ONCE on return rather than running one
redundant O(n) scan per missed tick. Purge is idempotent, so a skipped tick
loses nothing but the wasted scan (inv #10 — a flood/stall cannot amplify the
reap into per-tick work).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(st2110): strengthen ChannelPacketSource::drop bounded-block justification (#155/I2)

The blocking lock in Drop is correct and kept (chosen over try_lock): this Drop
is source teardown off the engine data plane, so inv #10 does not bind it; it
runs exactly once and must RELIABLY set consumer_gone (a try_lock that lost the
race would drop that one-shot teardown signal forever). The comment now spells
out that the block is bounded to <= one push's O(1) critical section (the ring
lock is never held across an await/socket call), cannot deadlock, and why
try_lock is wrong here specifically while right on push/poll_packet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(aes67): RED — serve() must apply the configured multicast egress interface (#156/S1)

configure_multicast_egress is wired but called only in tests. serve() must apply
it during socket setup. Proven with a bogus interface index behind a real
receiver (every send succeeds), so only applying+rejecting the egress can end
serve(); without the call serve() streams forever and the timeout elapses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(aes67): apply configure_multicast_egress in serve() socket setup (#156/S1)

serve() now selects the configured IPv6 multicast egress interface
(IPV6_MULTICAST_IF + hop-limit 255) before the first packet, instead of leaving
the wired configure_multicast_egress unused (dead outside tests). Unspecified
(the default until #103 supplies the index) configures cleanly; a misconfigured
interface fails fast rather than streaming on the OS default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(sap): RED — SapAnnouncer::run must apply the configured multicast egress interface (#156/S2)

configure_multicast_egress is wired on the announcer but called only in tests.
run() must apply it during socket setup. Proven with a bogus interface index:
run() must apply+reject it and END, not announce forever on the OS default —
without the call run() loops and the timeout elapses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sap): apply configure_multicast_egress in SapAnnouncer::run (#156/S2)

run() now selects the configured IPv6 multicast egress interface
(IPV6_MULTICAST_IF + hop-limit) before announcing, instead of leaving the wired
configure_multicast_egress unused (dead outside tests). Unspecified (the default
until #103 supplies the index) configures cleanly; a misconfigured index ends
the task with a warning rather than announcing on the wrong / OS-default egress.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(sap): RED — a malformed flood must not starve legit SAP of rate budget (#157/S3)

An in-module test drives fold_datagram with a budget of one fold per window: a
malformed-datagram flood must not spend that budget so a legit announce in the
same window is still folded. Currently limiter.allow() runs BEFORE parse, so the
first malformed datagram burns the budget and the legit announce is dropped by
the limiter (table stays empty).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sap): rate-gate the fold AFTER parse so malformed datagrams don't burn budget (#157/S3)

fold_datagram now parses BEFORE consulting the rate limiter: a malformed
datagram fails the cheap, bounded structural parse and consumes no budget, so a
malformed flood can no longer drain the shared fixed-window bucket and starve
legitimate SAP announces. The limiter still bounds the expensive O(n) RCU fold
for valid announces (F4 valid-flood behaviour unchanged, inv #10).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(sap): RED — the [::] SAP listener must bind dual-stack (#158/S4)

Asserts only_v6()==false AND reuse_address()==true after binding [::], mirroring
the webrtc media bind. tokio's UdpSocket::bind sets neither: SO_REUSEADDR is off
(the deterministic RED) and dual-stack is left to the host bindv6only sysctl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sap): bind the SAP sockets dual-stack via socket2 (only_v6=false, reuse_address) (#158/S4)

SapListener::bind and SapAnnouncer::bind now build the UDP socket through a
socket2 bind_dual_stack helper: an IPv6 ([::]) bind sets IPV6_V6ONLY=false so it
also accepts IPv4-mapped SAP (not left to the host bindv6only sysctl), and every
bind sets SO_REUSEADDR so listeners can share the well-known port and re-bind
promptly — mirroring the webrtc media bind (ADR-0042 IPv6-first). No raw FFI; the
crate stays unsafe_code = forbid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(adr): T013 status — AES67 is the first consumer; wiring lands under #103 (#159/S5)

The Status line claimed the seam had a "second concrete consumer landed" (AES67),
overclaiming a pipeline caller that does not exist yet and contradicting lines
13/159 which call AES67 the "first" consumer. Reworded: AES67 is the first
concrete consumer; the producer->AudioStore::publish_at wiring in pipeline.rs
lands under #103, so the seam is accepted and built, not yet wired end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style(sap): rustfmt the re-panel test + bind changes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sap): keep bind async with a justified unused_async allow (#158/S4)

The socket2 bind_dual_stack path is synchronous (dual-stack / SO_REUSEADDR must
be set before bind, so tokio's async UdpSocket::bind can't be used), which left
SapListener::bind / SapAnnouncer::bind with no await. Keep them async for API
stability (every caller and #103 awaits them, matching the peer transports) and
suppress clippy::unused_async with an inline justification (rule 20).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(cli): add the `aes67` feature (st2110 RX + aes67 TX) for #103 wiring

New CLI umbrella feature `aes67 = ["multiview-input/st2110",
"multiview-output/aes67"]` — RX = the ST 2110 receiver + AES67 L16/L24
depacketizer, TX = the raw-PCM multicast sender. Off by default; config types are
always parsed and a document declaring Aes67 in a build without this feature
fails validation (the display-kms precedent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(cli): RED — Aes67 source/output must fail closed without the aes67 feature (#103)

Mirrors the display-kms gate: a config declaring an Output::Aes67 or
SourceKind::Aes67 must FAIL a clear runnable check in a non-aes67 build, never be
silently skipped. ensure_aes67_outputs_supported / ensure_aes67_sources_supported
do not exist yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(cli): fail-closed gate for Aes67 source/output in a non-aes67 build (#103)

ensure_aes67_outputs_supported + ensure_aes67_sources_supported (outputs.rs)
mirror ensure_display_outputs_supported: in an `aes67` build they pass; in any
other build they return a clear, actionable error naming the offending
source/output(s) and the required feature. Wired into Pipeline::build (source
gate, up front) and build_outputs (output gate, beside the display gate), so a
config declaring AES67 without the feature FAILS validation rather than wiring a
dead stream. GREEN: the RED gate test now passes in both the default and the
`--features aes67` build; clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(cli): RED guardrail — AES67 source is audio-only, no video tile/registry (#103)

Two build-time guardrails protecting the just-merged MP-2 decode-once seam
(ADR-0030 §3): a Pipeline built with an AES67 audio source must contribute an
AudioStore (program-bus routed) but NO video TileStore, NO SourceRegistry
entry, and NO layout tile — and a mixed config's video source must still
decode-once-share at the per-axis supremum, unaffected by the aes67 branch.

RED: Pipeline::build returns Ingest { reason: "unsupported source kind for the
CLI pipeline" } because SourceKind::Aes67 falls through ingest_plan_for's
non-exhaustive `_` arm. The RX wiring commit that follows makes both GREEN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(cli): AES67 RX — receive ST 2110-30 audio into the program bus (#103)

Wire SourceKind::Aes67 as an AUDIO-ONLY source in Pipeline::build: branch at
the source-loop top to skip the entire video path (no TileStore, no
SourceRegistry entry, no ingest plan, no layout tile — the MP-2 decode-once
seam never sees it), build a per-source AudioStore that joins the program bus,
and queue an Aes67RxPlan. drive_streaming spawns one supervised RX thread per
plan (gated on program audio, like the audio decode plans) into the same
IngestSupervisor bounded stop+join teardown.

drive_aes67_rx drives an async multicast RtpReceiver + its receive loop on a
current-thread Tokio runtime (a select! interleaves the socket with a 2ms
drain poll), pumping depacketized units through the shared ADR-T013
RtpAudioRebaser — passing the REAL packet SSRC (not the WebRTC path's hardcoded
0) so an SSRC change re-anchors — into AudioStore::publish_at. The multicast
group:port comes from the config `multicast` override (the SDP parser ignores
the c= line by design); a missing/invalid override or unparseable SDP is a
fail-closed typed refusal.

Makes the two RED guardrail tests GREEN. All under the off-by-default `aes67`
feature; the ffmpeg-only build is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(cli): AES67 TX — multicast the program audio as ST 2110-30 RTP (#103)

Wire Output::Aes67 as a mux-free program-audio output. build_outputs builds
the serve-side Aes67Sender (stereo, matching the program bus; L16/L24 from the
config depth; frames_per_packet from ptime_ms) and threads its push handle onto
the pipeline (Pipeline.aes67_send_handles -> StreamEgress::spawn ->
consumer_main). The bake consumer pushes each post-loudnorm program AudioBlock
into every handle beside the display heads; a new RunnableOutput::Aes67 carries
the sender, and run_aes67_output binds the multicast Aes67UdpSender on a
current-thread runtime and serves it on its own media-clock timer until the
end-of-program pulse closes.

The ProgramBus is moved into the bake consumer, so TX cannot subscribe — it is
fed like a display head. Aes67Sender::next_packet_into silence-fills an empty
FIFO, so the multicast never gaps when a run carries no program audio (no
dedicated bus needed). Infallible: a bind/serve fault degrades the output,
never the program (inv #1/#10). Adds TX + pure-helper unit tests.

Crate-scoped gate (fmt, clippy -D warnings, ffmpeg-only + ffmpeg,aes67 check)
green on the TX code; the ffmpeg,aes67 test leg is verified next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(cli): AES67 test fixture builds JSON via Map insert (no Value indexing) (#103)

The aes67_source_value fixture built its serde_json object with `v["multicast"]`
IndexMut, which trips clippy::indexing_slicing under --all-targets -D warnings.
Build the object with serde_json::Map::insert instead. Test-only; no behaviour
change. clippy -p multiview-cli --features ffmpeg,aes67 --all-targets and fmt
are clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(cli): gate the ffmpeg+aes67 pipeline wiring in the ffmpeg-linked CI job (#96/#103)

The AES67 RX/TX (ST 2110-30 raw-PCM audio) CLI wiring in pipeline.rs is `#[cfg(feature="aes67")]` nested inside `#[cfg(feature="ffmpeg")]`. The bare-runner `feature-clippy` matrix cannot build it (ffmpeg-sys-next's build.rs needs libav), so a clippy or test break in that wiring reached main CI-green. Add an aes67 clippy+test pair to the existing ffmpeg-linked `feature-clippy-ffmpeg-webrtc` job, reusing its already-set-up pinned FFmpeg env — closing the false-green gap, analogous to why the webrtc-native leg exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(config): SourceKind::Aes67 multicast binding is a required config override, not SDP-derived (rule 27)

The `multicast` field doc over-promised "Absent => derived from the SDP connection". Actual behavior: the multicast group:port MUST come from the config `multicast` override and fail-closes with a typed error when absent, because the st2110 SDP parser deliberately ignores the `c=` connection line (the transport binding is carried in config by design). Reword to the honest, present-tense requirement. Doc-comment-only; no code or type change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(cli): aes67 leg is clippy-only on shared runners (libva absent) (#96/#103)

The ffmpeg-linked aes67 test binary links VAAPI and needs libva-drm.so.2 at
load time, which the bare GitHub runners do not have (exit 127). Drop the
aes67 TEST step; keep the CLIPPY step (matching the webrtc-native clippy-only
legs). The aes67 unit tests run in dev and belong on a dedicated
ffmpeg-runtime leg (tracked follow-up).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(cli): RED — aes67 fail-closed build/resolve guardrails (#96/#103)

Three cross-vendor (Codex) findings, each currently silent:
- a layout CELL bound to an audio-only aes67 source builds a dangling tile
  (no TileStore) instead of failing closed;
- a non-48 kHz ST 2110-30 session resolves even though the RX rebaser only
  rescales the RTP timestamp (it does not resample) — overlaps/gaps;
- a zero ptime_ms is silently coerced to a 1-frame packet flood.

RED: build_aes67_output_rejects_a_zero_ptime,
resolve_aes67_source_rejects_a_non_48khz_session,
a_layout_cell_bound_to_an_aes67_source_is_rejected all fail (10 passed; 3
failed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cli): reject a layout cell bound to an AES67 source (#96/#103)

An AES67 / ST 2110-30 source is audio-only (no TileStore), so the source loop
skips the whole video path for it. A layout cell referencing it would keep
tile geometry with no backing store — a dangling tile. Fail closed at build
with a clear ConfigError naming the source, instead of silently retaining the
cell.

Greens a_layout_cell_bound_to_an_aes67_source_is_rejected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cli): reject a non-48 kHz AES67 session (RX does not resample) (#96/#103)

resolve_aes67_source accepted any clock rate the SDP parser admits (48 kHz and
96 kHz). The RX rebaser (ADR-T013) only rescales the RTP timestamp onto the
48 kHz store index; it does not resample the samples, so a 96 kHz session
publishes 96 samples/ms against a +48/ms anchor — overlaps/gaps/wrong pitch.
Reject a non-48 kHz clock_rate fail-closed at build.

Greens resolve_aes67_source_rejects_a_non_48khz_session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cli): reject a zero AES67 ptime instead of silently coercing it (#96/#103)

build_aes67_output computed frames_per_packet = ptime_ms * 48000 / 1000 then
`.max(1)`, silently turning ptime_ms = 0 into a 1-frame (~0.02 ms) packet
flood. Reject ptime_ms == 0 fail-closed. Note the floor itself never truncates
(ptime_ms * 48000 / 1000 == ptime_ms * 48 exactly for any u32), and an
oversized ptime is already rejected by Aes67Sender::new's frames-per-packet
bound — so zero was the only silently-coerced value.

Greens build_aes67_output_rejects_a_zero_ptime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(cli): RED — stable AES67 SSRC + program-audio requirement (#96/#103)

Two cross-vendor (Codex) findings:
- aes67_ssrc_for folds only the output id via DefaultHasher (not toolchain-
  stable; two outputs sharing an id collide). The RED asserts a 2-arg
  (id, group:port) signature, group+port entropy, and a pinned FNV-1a value.
- an AES67 source/output with no program audio silently receives/emits
  silence. The RED asserts a new Pipeline::ensure_aes67_has_program_audio().

Both fail to COMPILE against the current code (E0061 arg count; E0599 missing
method) — the actual RED failing output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cli): stable AES67 SSRC (FNV-1a, id+group:port) + require program audio (#96/#103)

Greens the two Batch-B REDs together (each RED is a compile error, so a
split-per-finding commit would leave a non-buildable intermediate):

- aes67_ssrc_for now folds the output id AND its multicast group:port with
  FNV-1a (a fully-specified, toolchain-stable algorithm, mirroring the SAP
  stable_hash), replacing the non-stable DefaultHasher/SipHash fold and its
  wrong "stable across runs" doc. Group+port entropy stops two outputs that
  share an id from colliding. Pinned value asserted.
- Pipeline::ensure_aes67_has_program_audio() fails closed at the top of
  drive_streaming when an AES67 source/output is configured without program
  audio (the RX/TX only exist on the ProgramBus) — no more silent silence.

Greens aes67_ssrc_is_stable_nonzero_and_folds_in_the_multicast_binding and
an_aes67_source_without_program_audio_is_rejected_before_going_on_air (14
passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(input): RED — AES67 RX must drop wrong-payload-type RTP (#96/#103)

The ST 2110-30 receive path decodes ANY RTP arriving on the socket as PCM: the
RTP payload type is parsed but dropped at the St2110Packet seam, so a stray /
multiplexed stream on the same 5-tuple whose payload length is a whole number
of sample groups is decoded as our audio (RFC 3550 demuxes by payload type).

RED: producer_drops_packets_whose_payload_type_does_not_match_the_session
fails to compile (E0560 St2110Packet has no field payload_type; E0061
Aes67AudioProducer::new takes 2 args, 3 supplied).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(input,cli): filter AES67 RX by RTP payload type (#96/#103)

St2110Packet now carries the parsed RTP payload_type (the video assembler
ignores it, like ssrc). Aes67AudioProducer::new takes the SDP-negotiated
payload type and next_audio drops any packet whose PT differs — a stray /
multiplexed RTP stream sharing the multicast 5-tuple is no longer decoded as
our PCM (RFC 3550 demuxes by PT). The drop is bounded by MAX_PACKETS_PER_POLL
(inv #1), exactly like the malformed-payload skip. pipeline.rs plumbs
plan.session.payload_type into the producer.

Greens producer_drops_packets_whose_payload_type_does_not_match_the_session
(42 passed in aes67_audio_rx; video st2110 tests unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style(cli,input): rustfmt the aes67 remediation edits (#96/#103)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style(input): hoist a test const above statements (clippy) (#96/#103)

items_after_statements: move STRAY_PT to the top of the payload-type-filter
test. Behaviour-neutral…
aperim-agent added a commit that referenced this pull request Jul 11, 2026
…aling at the poll seam (#197) (#268)

* test(hal): RED — assert exact fdinfo media fraction through the real poll seam (#36 HAL-2 rd3)

Round-3 review (Codex #266) on the F1 delta:
- F1: the interval-scaling regression fed `latest_at` DIRECTLY to the tracker,
  never exercising `sample_all_with_media`'s walk-capture — it caught only a
  fixed-divisor regression. Drive it through the real `sample_all_with_media` /
  `LoadSource::poll` seam instead.
- F2 (rule-19): the probe/poller tests asserted a SATURATED 1.0 (a 1e15-ns
  counter delta over a sub-ms real interval), which masks wrong elapsed/counter
  math across a broad range. Restore EXACT coverage — 0.5 over a 1 ms injected
  interval, and 0.25 for the SAME counter delta over 2 ms — via an injected
  snapshot clock so the divisor is exact and deterministic at the real seam.

The tests reference the not-yet-existent `SnapshotClock` + `with_clock` seam, so
`cargo test -p multiview-hal --features vaapi` fails to compile (RED):
  error[E0432]: unresolved import `super::super::linux_sysfs::SnapshotClock`
  error[E0599]: no method named `with_clock` found for `SysfsLoadProbe` (x2)
  error: could not compile `multiview-hal` (lib test) due to 3 previous errors

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(hal): injectable SnapshotClock — exact fdinfo media fraction at the poll seam (#36 HAL-2 rd3)

Round-3 GREEN for the RED above (Codex #266 review). `SysfsLoadProbe` reads its
media-snapshot instant from an injectable `SnapshotClock` (default `Monotonic` =
the real monotonic clock in production; production behaviour unchanged). A unit
test injects a controllable clock via `with_clock` (#[cfg(test)]) so the
probe/poller tests drive an EXACT snapshot-to-snapshot interval through the real
`sample_all_with_media` / `LoadSource::poll` seam and assert the exact merged
media fraction (0.5 over 1 ms; 0.25 for the SAME counter delta over 2 ms)
instead of a saturated 1.0.

- Finding #1: the interval-scaling proof now runs at the real poll seam (not
  only the pure tracker), so a regression that timestamps at poll-entry rather
  than at the snapshot walk is caught.
- Finding #2 (rule-19): restores deterministic EXACT coverage — the saturating
  1e15-ns counter delta is gone; the fraction is asserted exactly.

Enabling a hardware feature does not change any public API: `SnapshotClock` is
pub(crate), `with_clock` + the `Manual` variant are #[cfg(test)], and production
still reads the real monotonic clock.

Gate (crate-scoped): fmt clean; clippy vaapi/i915-pmu/default --all-targets
clean; test vaapi fdinfo_walk 12 pass; test default all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(hal): honest-frame the fdinfo poll-seam tests — interval-scaling + de-saturation, not capture-POINT (#197)

Codex review of #268 (2026-07-11): the injected `SnapshotClock` returns a
constant value within a single poll, so the probe/poller tests prove the
divisor tracks the snapshot-to-snapshot INTERVAL (0.5 over 1 ms, 0.25 for
the same delta over 2 ms) and de-saturate the fold — but they do NOT
distinguish walk-capture from poll-entry-capture within one poll (that would
need walk-instrumentation, not worth it for a <0.001%-impact detail — the
walk is microseconds).

Reword the test comments to claim only what they prove and record the
within-poll capture-POINT as an accepted negligible residual (rule 27).
Comment-only; every changed line is a `//` comment, assertions unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
aperim-agent added a commit that referenced this pull request Jul 11, 2026
…-split guards (F1)

Cross-vendor panel (3-lens Codex) on PR #274 found the implementation clean
but the single flood test too loose to guard the contract (rule 18). Three
test-only additions; no change to code under test:

1. Tighten the flood assertion from `polled <= 1000` to exactly the per-poll
   budget (== MAX_PACKETS_PER_POLL, pinned at 64): a regression to any wider
   budget now fails where the ceiling passed silently (mutation-killing).
2. Positive control: a valid frame still completes within budget (pulls
   exactly its line packets) — guards against an over-aggressive bound
   breaking normal operation.
3. Boundary-split: a frame whose marker is the 65th unit — call 1 yields
   Ok(None) after exactly 64 pulls, call 2 returns the COMPLETE raster —
   proves in-progress assembler state is preserved across the budget yield,
   no line dropped or duplicated.

Evidence: against the pre-fix unbounded loop, #1 fails (polled 5001 vs 64)
and #3 fails; #2 passes either way. All green against the shipped bound.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
aperim-agent added a commit that referenced this pull request Jul 11, 2026
…dget (F1-video; inv #1) (#125) (#274)

* test(st2110): RED — next_frame must be bounded work under a malformed flood (F1)

A malformed-packet flood makes one next_frame() call loop until the
source empties, so a single sample can do unbounded work and delay the
output clock (panel F1, inv #1). The RED asserts a bounded per-call poll
budget; against the current unbounded loop it polls 5001 of a 5000-packet
flood, blowing past the ceiling.

This is the video (ST 2110-20 RX) analogue of the AES67 next_audio F1 fix
(983c129b): the identical unbounded-loop shape, pre-existing on main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(st2110): bound next_frame to a per-poll packet budget (F1, inv #1)

next_frame() pulled source packets in an unbounded loop until the source
drained, so one sample could do unbounded work and delay the output clock
under a malformed-packet flood (panel F1, CRITICAL). This is the video
(ST 2110-20 RX) analogue of the AES67 next_audio fix (983c129b): the
identical unbounded-loop shape, pre-existing on main.

Cap the pull loop at St2110Producer::MAX_PACKETS_PER_POLL (64) units per
call; budget spent with no frame closed yields Ok(None) and the pump
re-polls next tick. A malformed flood is drained at the budget rate per
tick rather than all at once — a sample is now bounded work whether it
runs on the output-sample side or a shared ingest thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(st2110): tighten flood bound + add positive-control and boundary-split guards (F1)

Cross-vendor panel (3-lens Codex) on PR #274 found the implementation clean
but the single flood test too loose to guard the contract (rule 18). Three
test-only additions; no change to code under test:

1. Tighten the flood assertion from `polled <= 1000` to exactly the per-poll
   budget (== MAX_PACKETS_PER_POLL, pinned at 64): a regression to any wider
   budget now fails where the ceiling passed silently (mutation-killing).
2. Positive control: a valid frame still completes within budget (pulls
   exactly its line packets) — guards against an over-aggressive bound
   breaking normal operation.
3. Boundary-split: a frame whose marker is the 65th unit — call 1 yields
   Ok(None) after exactly 64 pulls, call 2 returns the COMPLETE raster —
   proves in-progress assembler state is preserved across the budget yield,
   no line dropped or duplicated.

Evidence: against the pre-fix unbounded loop, #1 fails (polled 5001 vs 64)
and #3 fails; #2 passes either way. All green against the shipped bound.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(st2110): note next_frame budget bounds packet count, not per-poll time (F1)

Panel rule-27 residual: the per-poll budget bounds packet COUNT, not
wall-clock time — it assumes each poll_packet + depacketize step is itself
bounded, which the fixed ST 2110-20 depacketize is (bounded by the datagram
size). One honest line on next_frame, matching the AES67 F1 precedent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
aperim-agent added a commit that referenced this pull request Jul 12, 2026
Add the ProbeStatus / DeviceCapability / CgroupLimits / HostInfo /
DetectionInfo wire types + the observed_at / devices / host / detection
fields on SystemCapabilities, with serde tests encoding the three
contracts: ProbeStatus is snake_case on the wire, unknown optionals are
omitted (never a fabricated zero), and observed_at is ALWAYS serialized
(the provenance anchor, ADR-M014 review finding #2). The RED serde config
is deliberately wrong on all three (no rename_all, no skips, observed_at
carries a skip) so the tests fail first; the green commit fixes them.
Types are primitives/enums only — zero multiview-hal dep (the #263/W030
boundary). Per ADR-M014 §2/§6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file github_actions Pull requests that update GitHub Actions code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant