v0.51.0

CaviraOSS/LongMemoryv0.51.0Sep 3, 2026by github-actions[bot]

Upgrading?

AI Summary

This release adds mutual TLS support for SIP trunks, allowing verification of caller identity and routing based on certificates. It also improves observability by exposing dead collector status via metrics and enforcing log floors to prevent silent log muting.

Key Highlights

  • Mutual TLS support: `[sip.tls].client_ca` and `[sip.tls_client].client_cert` for outgoing connections; `peer_cert_san` for routing.
  • New metrics for collector health: `siphon_ai_otlp_collector_up` and `siphon_ai_hep_collector_up`.
  • Enforcement of log floor: `--log` directives now prepend `warn,` by default to prevent silent muting of logs.
  • Updated forge-media to v2026.09.02.

New Features

  • Mutual TLS for SIP trunks (client auth, peer cert SAN routing).
  • Collector up/down metrics (OTLP & HEP).
  • Log floor enforcement logic.
  • New `--log-no-floor` flag to revert old behavior.

Full Release Notes

### Added

- **A dead collector is now visible on `/metrics`, for both exporters**
  ([#596](https://github.com/thevoiceguy/siphon-ai/issues/596)). The 0.51.0 OTLP soak
  killed the collector for 46 minutes under 200 concurrent calls and **no metric moved**:
  `siphon_ai_otlp_log_records_dropped_total{reason="queue_full"}` guards the daemon's own
  queue, which never fills against a dead collector because the SDK accepts records
  instantly and discards the batch one hop later; the only evidence was an
  `opentelemetry_sdk` ERROR line that a target-named log filter mutes. HEP had the same
  blind spot since #460, and CLAUDE.md §4.7 has described a `siphon_ai_hep_collector_up`
  gauge that did not exist.
  - **OTLP**: the span and log exporters are wrapped so every batch's outcome is observed.
    New gauge `siphon_ai_otlp_collector_up` (1/0, shared by spans and logs — same
    endpoint), new `reason="collector_down"` on `siphon_ai_otlp_log_records_dropped_total`
    counting every record in a failed batch, and new
    `siphon_ai_otlp_spans_dropped_total{reason="collector_down"}` for spans. All present
    from startup. `queue_full` keeps meaning what it meant.
  - **HEP**: hep-rs grew `UdpHepSink::send_failures` (hep-rs #3), so a send the socket
    refused — `ECONNREFUSED` after a dead collector's ICMP port-unreachable — is now
    `siphon_ai_hep_packets_dropped_total{reason="collector_down"}`, and
    `siphon_ai_hep_collector_up` is derived from it each 10 s sample: 0 when any send was
    refused in the interval, 1 otherwise. Derived over the window because connected UDP
    reports the refusal on the *following* send, so a point sample would flap. Every packet
    handed to the sink now lands in exactly one of sent / queue_full / collector_down. UDP
    caveat, documented: a black-holed collector (no ICMP back) still looks up.
  - Reference alerts `SiphonAIOtlpCollectorDown` and `SiphonAIHepCollectorDown` in
    `examples/observability/rules/alerting.yml`; CONFIG.md, DEPLOY.md, HEP.md, DEV_PLAN R12
    and §4.7 now describe what exists.
- **The `warn` log floor is enforced, not just default**
  ([#597](https://github.com/thevoiceguy/siphon-ai/issues/597)). A `--log` / `RUST_LOG` /
  `SIPHON_AI_LOG` directive that names only targets (`siphon_ai=debug,siphon=info` — the
  shape of every example the repo used to ship) *replaced* the built-in filter, floor
  included, and silently muted every crate it did not name: `hep_rs` reporting a dead
  collector, `opentelemetry_sdk` reporting a failed export. The fail-safe held only while
  nobody set a filter. Now such a directive gets `warn,` prepended — it can turn crates up
  but cannot mute the ones it did not name — while a directive that states its own global
  level (`info,siphon_ai=debug`, or a bare `off`) is left alone. The same rule applies to
  `PUT /admin/v1/log`, whose response `filter` is what was actually installed. The daemon
  logs `log filter active` with the effective directive and its source at startup.
  **`--log-no-floor`** (`SIPHON_AI_LOG_NO_FLOOR`) restores the old replace-everything
  behaviour for anyone who genuinely wants silence, and says so at startup with the one
  `warn` that still gets out.

- **Mutual TLS on the SIP trunk: verify who is calling, and route on it**
  (siphon-rs [#129](https://github.com/thevoiceguy/siphon-rs/issues/129) /
  [#130](https://github.com/thevoiceguy/siphon-rs/pull/130), pin `v2026.08.25` →
  `v2026.09.03`). Until now a TLS trunk was encrypted but anonymous: the listener never
  asked for a client certificate, so nothing distinguished a partner node from anyone who
  found port 5061, and the only handles for a route were what the INVITE *claimed* about
  itself. Now:
  - **`[sip.tls].client_ca` + `client_auth = "optional" | "required"`** make the TLS
    listener request a client certificate and verify it against a PEM CA bundle.
    `required` fails the handshake for a peer with no certificate — it never sends a SIP
    message; `optional` admits such a peer (no identity) while still refusing a
    certificate that does not chain, so a fleet can roll certificates out before the
    switch is thrown. Both halves must be set together; the bundle must exist at load;
    the mode is validated at load; all three by `siphon-ai check`. Applies to the SIP TLS
    listener only (browsers on `[sip.wss]` do not present client certificates).
  - **`[sip.tls_client].client_cert` + `client_key`** present a certificate on every
    outgoing TLS connection to a peer that asks — mutual TLS *toward* a trunk. Key must
    be `0600`, as the listener key already is.
  - **`peer_cert_san` route match key** (`docs/DIALPLAN.md` §4.6): matches when any name
    the verified certificate asserts — URI SANs (where RFC 5922 puts a SIP identity), DNS,
    IP, e-mail SANs, then the CN — satisfies the predicate. A call with no verified
    certificate has no candidate names and can never match it, so a `.*` regex cannot
    admit an unauthenticated caller the way it can for an absent header. A dialplan naming
    the key on a daemon whose listener never asks for a certificate is **refused at load**,
    naming the route — such a route could never fire. `siphon-ai route-test
    --peer-cert-san <name>` (repeatable) models it offline.
  - **Observability**: every INVITE on a verified connection logs `peer_identity` (subject
    + SANs) and the SHA-256 fingerprint at `info` with the peer address, and the routed /
    no-match lines carry `peer_cert_names`; new counter
    `siphon_ai_tls_peer_identity_total{result="verified"|"none"}` per INVITE on TLS/WSS
    (documented in DEPLOY.md, with the rollout recipe); `GET /admin/v1/calls` rows gain
    an optional `peer_identity` field (additive — absent for every call that presented
    none, so existing rows are byte-identical); `print-config` shows `tls_client_auth` and
    `tls_client_identity` under `sip`.
  - **Not in this release**: the identity is not yet on the CDR (would be v10) and
    `[sip.admission]` / `[[trunk]]` do not consult it — routing is the authorization
    point for now; the default route (or the absence of one → 404) is where an
    unrecognized certificate ends up.

### Changed

- **forge-media pin `v2026.08.26` → `v2026.09.02`** — a routine roll-up.
  Nothing in it was asked for by this project and nothing in it changes
  this daemon's behaviour; the bump is to stop the pin drifting.

  Three tags passed (`v2026.09.01` was superseded untagged by
  `v2026.09.01.1`). Across the whole range only three files we build
  changed. **forge-engine** gives `MediaSession` a dynamic payload-type
  map — a B2BUA learns the far leg's negotiated payload types only when
  its SDP answer arrives, and a re-INVITE can change them mid-call — held
  in an `AtomicU32` so the per-packet forwarding path stays lock-free.
  It is additive: every constructor seeds it from the frozen
  `transcoding_config.payload_type_map` the forwarding path already read,
  so a consumer that never calls the setter behaves identically, and we
  have no leg whose payload types arrive late. **forge-sdp** moves its
  `sip-sdp` dependency from a submodule-relative path to
  `tag = "v2026.08.25"` — the same tag this workspace pins every other
  `sip-*` crate to, so the lock still holds exactly one `sip-sdp`
  (verified; differing tags would have linked two copies of the SDP
  negotiator).

  Everything else in the range is **forge-conference** — DTMF dial-out,
  PIN auth, wait-for-moderator, an 80 ms RFC 2833 duplicate window. We
  do not depend on that crate (our conferencing is forge-mixer), and the
  dedup lives in `forge-conference`, not `forge-dtmf`, so this daemon's
  DTMF path is untouched.

  **siphon-rs is deliberately not bumped**: its newest tag is still
  `v2026.08.25`, which we already pin — and which is the tag this forge
  release records in `external/siphon-rs`, so the embedded pair matches.

  Verified with the full workspace suite, a `--features webrtc` build,
  and all 60 SIPp signaling scenarios.

### Added

- **Log records now ship over OTLP carrying the trace context of the span
  they were emitted inside** — `[observability.otlp.logs]`, off by default
  (#589).

  Since 0.22.0 the daemon exports one trace per call, and since 0.23.0 that
  context reaches the WS server. Logs never came along: a line scraped from
  journald carries no `trace_id`, because the context lives in the process
  and is gone by the time the text is written. Only an in-process bridge can
  attach it. Now opening a call's trace in Tempo / Jaeger / Cloud Trace shows
  the lines that explain each span, rather than sending you to a separate log
  search to match on Call-ID and timestamp by hand.

  Same endpoint, timeout and resource attributes as the span pipeline, with
  its own export `level` so the console and the collector can have different
  appetites (narrower only — the global filter is still a floor over every
  layer, which `docs/CONFIG.md` states rather than papers over). Enabling it
  without `[observability.otlp]` is a fatal config error: with the span layer
  off nothing attaches a context, so every record would ship uncorrelated —
  which is exactly what journald already gives you.

  **Two bugs found by running it against a real collector rather than
  trusting the wiring:**

  - The OTel batch processor raises `tracing` events of its own from inside
    `emit` ("queue full", "emitted after Shutdown"). Dispatched from within a
    layer's `on_event`, a nested event runs a second filter pass over the
    same thread-local state the outer event is still using and leaves its
    bits behind — a `debug_assert` panic in debug builds, and *silently wrong
    per-layer filtering* in release, which is worse. So nothing calls into
    the SDK from `on_event`: the layer stamps the trace context, which must
    happen there while the span's context is still current, and hands the
    record to a bounded queue; a worker thread does the rest. That is also
    the shape §4.7 asks for — queue, return, never block — and it gives drops
    a real counter, `siphon_ai_otlp_log_records_dropped_total`.
  - Teardown flushed the provider while the layer was still feeding it. The
    layer is now closed and its queue drained *before* the flush, so the
    records that say why the daemon is going away are not the ones lost.

  The SDK's own diagnostics and its gRPC stack (`opentelemetry`, `tonic`,
  `h2`, `hyper`, `tower`, `reqwest`) are muted on the export layer only —
  exporting makes them talk, and shipping that would make export cause
  export. They still reach the console and `GET /admin/v1/errors`, which is
  where `BatchLogProcessor.ExportError` shows up when a collector is
  unreachable.

- **The daemon can emit its logs as JSON** — `--log-format json`, or
  `SIPHON_AI_LOG_FORMAT=json` for a systemd unit (#588). One object per
  line, the event's own fields flattened to the top level and the current
  span's fields under `span`, which is where `call_id` lives.

  The point is what happens when logs leave the box. The daemon attaches a
  lot of structure to its events — `call_id`, `route`, `from_user`,
  `request_uri_user`, `register_source` — and in text form every one of
  those is a substring that a shipper has to regex back apart, badly, and
  that silently stops carrying a field the day a call site gains one. As
  keys they land as queryable fields in Cloud Logging / Loki / Elastic and
  survive new call sites without anyone touching a regex.

  **`text` remains the default and its output is unchanged**, byte for
  byte — it is the right thing for `journalctl` on one node, and the
  shipped fail2ban filter's `<HOST>` extractor string-matches it.
  Switching a node to `json` breaks that regex silently (bans stop, the
  jail still looks healthy), which is called out in both
  `docs/CONFIG.md` → *CLI flags & environment* (a new section covering
  `--config` / `--log` / `--log-format` and their env twins) and the
  `docs/OPERATIONS.md` quick-reference table.

  Format and filter stay orthogonal: `--log-format` never changes which
  events are emitted, and `PUT /admin/v1/log` never changes how they are
  rendered. The `check` / `print-config` / `route-test` reports are
  written straight to stdout, not through `tracing`, so they stay
  human-readable in both modes.

- **Three real browsers now call the daemon every night and have to
  prove audio in both directions** (`DEV_PLAN_WebRTC.md` §5, item 2).
  `test-harness/browser/` is a Playwright harness — Chromium, Firefox
  and WebKit each REGISTER over WSS, place a call the daemon answers
  with a forge-webrtc leg, exchange audio, hang up, and give the RTP
  port pair back. It runs nightly
  (`.github/workflows/browser-interop.yml`), not per-commit: the
  per-commit media coverage is the in-process loopback, and what a real
  browser adds is whether *its* stacks agree with ours — which changes
  when browsers ship.

  Two-way audio is proved by **frequency, not level**, so neither
  direction can be satisfied by an echo. The page sends an identifiable
  tone; the harness's WS server *measures* the dominant frequency of
  what arrives and plays 900 Hz back; the page asserts inbound audio
  energy from `getStats()`.

  The interesting part was that every engine difference was in the
  browser's *audio plumbing*, not in DTLS or Opus — the media plane has
  been identical across engines so far. Chief among them: **headless
  Firefox has no audio output device**, so its `AudioContext` never
  leaves `suspended` and `resume()` never settles — with or without a
  user gesture, with or without every `media.autoplay.*` pref. Awaiting
  it hung the call before the INVITE was built. The lab page now waits
  with a budget and falls back to the engine's capture device, which is
  why the server measures the frequency instead of assuming it.

  `examples/browser-sip/index.html` gained the parameters that make
  this possible — `?tone=1` (send a tone, report what comes back on
  `window.__labAudio`) and `?sipjs=<url>` (load the SIP stack from
  anywhere, so a nightly run never depends on a CDN) — and its copy no
  longer says the test call carries no audio, which stopped being true
  when the browser leg landed.

- **A browser call is now tested end to end on every commit, and CI
  builds the `webrtc` feature at all** (`DEV_PLAN_WebRTC.md` §5, Phase
  3 item 1). Two loopback layers, ~2 s each, no browser and no
  container:

  - `crates/webrtc-glue/tests/loopback.rs` — a browser-shaped peer
    dialing a real `WebRtcLeg`: ICE nomination, DTLS, SRTP both
    directions, the Opus transcode, teardown, and fifteen calls back to
    back as a leak tripwire.
  - `crates/core/tests/webrtc_call.rs` — the same peer dialing the
    **acceptor**: an INVITE carrying a real forge-webrtc offer, the
    controller against a real WS server, and audio making the full
    round trip (browser → SRTP → decode → WS bridge → echo → encode →
    SRTP → browser). Plus the plan's acceptance test on this
    transport: drop the peer with no BYE and the inactivity watchdog
    must tear the call down **and** hand the RTP port pair back.

  **CI now runs `clippy` and `cargo test` with `--features
  siphon-ai/webrtc`** alongside the default pass. Until now none of the
  browser-leg code — the acceptor branch, the tap, the ICE/DTLS metrics
  — was compiled or linted in CI, while the release artifacts ship it
  enabled. That gap is closed.

  The loopback immediately pinned something the design had only
  reasoned about: **a vanished peer produces no event whatsoever** — no
  `Closed`, no `Failed`, no RTP, for as long as you care to watch.
  forge-webrtc sends RFC 7675 keepalives but never fails a transport
  when the replies stop, so `[media].inactivity_timeout_secs` really is
  the only thing that reclaims the slot. There is now a test asserting
  that gap, which fails with instructions if forge-webrtc ever learns
  to report it.

- **A browser call now says where it is in ICE/DTLS, live — in the
  admin API and in sightglass** (`DEV_PLAN_WebRTC.md` §4.6, completing
  it). `GET /admin/v1/calls` rows and `GET /admin/v1/calls/{id}/stats`
  carry `webrtc_state`: `connecting` (ICE checks running — no path
  yet), `ice_connected` (a pair is nominated and DTLS is handshaking),
  `connected` (SRTP keys installed, media flowing), `failed`, `closed`.

  **A call stuck on `ice_connected` has a working network path and
  failing crypto**; one stuck on `connecting` has no path at all. That
  is the same split `siphon_ai_webrtc_legs_total{result}` reports after
  the fact — this is it while the call is still up, which is when an
  operator can do something about it.

  The key is **absent for a classic SIP leg** rather than empty: absent
  means "not a browser call", never "unknown". Both are additive
  optional fields, so an older client ignores them and a newer client
  against an older daemon sees nothing.

  Sightglass grows a **MEDIA** column on the calls tab (`ice` → `dtls`
  → `webrtc`, `—` for a classic leg) and an `ice/dtls` line in the call
  detail pane. The column **hides itself when no visible call is a
  browser call**, the way the Node column does on a single-node fleet:
  the table is always short of width, and a fleet with no browser
  traffic should not pay for the column.

### Changed

- **CDR schema v8 → v9: every record now says how the call arrived
  (`leg_transport`) and how its audio was carried (`media_type`)**
  (`DEV_PLAN_WebRTC.md` §4.6). **Read this if you parse CDRs.**

  `leg_transport` is `udp` | `tcp` | `tls` | `ws` | `wss` — the
  transport an inbound INVITE arrived on, or an outbound gateway's
  configured transport. `media_type` is `rtp` | `srtp` | `webrtc`.

  The bump exists because with browser calls the record stopped being
  able to answer an obvious question. A WebRTC leg and a plaintext UDP
  leg produced *identical* CDRs apart from the codec, even though one is
  DTLS-SRTP over WSS and the other is cleartext RTP over the open
  internet — and `media_type` is the field a compliance report reads,
  where `rtp` is the only value meaning the audio was in the clear.

  Two things a consumer should know:

  - **`media_type` reports what happened, not what was configured.** A
    `[[gateway]].srtp = "preferred"` trunk whose peer answered plaintext
    records `rtp`, because that is what crossed the network.
  - **Both fields are omitted where they are genuinely unknown** rather
    than defaulted: a record written by an older daemon, and a
    delayed-offer call that failed negotiation before any media existed
    (that one has `leg_transport` and no `media_type` — claiming `rtp`
    would say cleartext audio flowed when none did). Gate on
    `version >= 9` rather than probing for the keys.

  **CSV sinks gain two columns (50 → 52)**, appended as always, so
  position-keyed ingestors keep working and anything asserting an exact
  column count does not. `webrtc` is a distinct value rather than `srtp`
  with a flag because the negotiation, the failure modes and the metrics
  all differ — see `siphon_ai_webrtc_legs_total`.

### Added

- **Browser calls are now observable: ICE and DTLS are timed and
  counted apart, and a leg's codec cost is measured**
  (`DEV_PLAN_WebRTC.md` §4.6). Until now a browser call that never got
  audio produced one log line and no metric, and "connect failed"
  covered two unrelated faults.

  Five metrics, all emitted only by a WebRTC leg:
  `siphon_ai_webrtc_legs_total{codec,result}`,
  `siphon_ai_webrtc_legs_ended_total{reason}`,
  `siphon_ai_webrtc_ice_seconds`, `siphon_ai_webrtc_dtls_seconds`, and
  `siphon_ai_webrtc_transcode_seconds{direction}`. All five are
  documented in `docs/DEPLOY.md`.

  **The ice/dtls split is the point.** `result="ice_timeout"` means the
  setup budget expired with *no* candidate pair nominated — there is no
  path, so the answer is NAT, `[webrtc].stun_servers`, or a
  `[media].rtp_port_range` the firewall never opened. `dtls_timeout`
  means a pair *was* nominated and the handshake still did not finish:
  the path works and the crypto does not. Those have different fixes,
  and a single "connect failed" counter would have hidden which one you
  had. Getting the split required making the setup wait **event-driven**
  (`SetupOutcome`, `webrtc-glue::leg::await_setup`) rather than polling
  the connection state the way it did before — polling collapses ICE
  nomination and DTLS completion into one "connected" and throws away
  the transition an operator needs.

  `siphon_ai_webrtc_legs_ended_total{reason="inactivity"}` is this
  daemon's **consent-freshness signal**. The plan asked for consent
  failures; forge-webrtc sends RFC 7675 keepalives but does not fail a
  transport when the replies stop, so there is no upstream event to
  count. Silence is what actually tells us a browser is gone, and the
  inactivity watchdog is what gives the call slot and its RTP port pair
  back — so it is counted as the real detector rather than a metric
  named after a mechanism we do not have.

  `siphon_ai_webrtc_transcode_seconds` is the plan's per-call transcode
  cost: wall time spent inside the codec, which for pure computation
  with no I/O is CPU time in all but name. It excludes SRTP protect and
  the socket write, and is recorded **once per leg** rather than per
  frame — 50 histogram observations a second per call would cost more
  than the work being measured (CLAUDE.md §4.3). Read next to the
  `codec` label, it is the capacity number for browser traffic, and the
  case for `[webrtc].prefer_g711` on a busy node.

- **Browser calls now draw an RTP port pair from the same pool SIP calls
  do** (`DEV_PLAN_WebRTC.md` §4.4). A WebRTC leg binds **one** socket
  (BUNDLE + `a=rtcp-mux`) where a classic leg binds an RTP/RTCP pair,
  and until now that socket came from wherever the OS put it —
  outside `[media].rtp_port_range` entirely.

  The accounting reason is the obvious one: `siphon_ai_rtp_port_pairs_allocated`
  would under-report real load, and `[media].reserved_outbound_calls`
  would be silently bypassed by browser traffic while still losing the
  slot it protects — the exact starvation the setting exists to
  prevent, made invisible. But the reason an operator hits **first** is
  simpler and worse: a socket outside `rtp_port_range` is unreachable
  through the firewall they opened for RTP. Media accounting can be
  reconciled after the fact; a blocked media path is a broken call in a
  deployment that did everything right.

  A browser leg now holds a whole **pair** for its lifetime, under the
  same floor an inbound SIP call uses, and forge-webrtc binds its
  socket to that pair's RTP port. It holds a pair rather than a single
  port because a pair is the unit the pool, the gauge and the reserved
  band all count in — charging a browser call half a slot would make
  `reserved_outbound_calls` mean two different things depending on who
  called. A browser call refused by the floor gets the same `503` +
  `Retry-After` and the same `siphon_ai_rtp_reserve_blocks_total`
  increment a SIP call does.

  The reservation releases on **`Drop`**, so no teardown path can leak
  a slot — and because the capacity gauge is *sampled from pool truth*
  rather than incremented at call sites, browser calls show up in it
  with no new metric, and Phase 0's soak assertion that the gauge
  returns to zero now covers WebRTC legs for free.

  Needs forge-media [#134](https://github.com/thevoiceguy/forge-media/pull/134)
  (`TransportConfig::local_port`, `SessionManager::reserve_port_pair`).

- **A browser can now place a call that carries audio** — the end of
  Phase 2's core path in `docs/design/DEV_PLAN_WebRTC.md`. An INVITE
  arriving over WS/WSS with a WebRTC-shaped offer is answered by
  forge-webrtc, and the resulting peer connection is handed to the
  controller as a `MediaLeg::WebRtc` alongside the classic RTP one.
  Verified with a real headless-Chrome call end to end: Chrome → WSS →
  the WebRTC leg → Opus decode at 16 kHz → the WS bridge → the echo
  server → encode → Chrome, **1216 frames each direction, zero decode
  or encode errors**.

  Three things the classic path does are deliberately skipped for a
  browser leg, because doing them would be wrong rather than merely
  unnecessary: the **SRTP policy gate** (it would reject
  `UDP/TLS/RTP/SAVPF` under the default `srtp_mode = off` and turn a
  working call into a 488 — the exact misdiagnosis §4.1 exists to
  end), the **profile tweaks** that rewrite an m-line for sip-sdp's
  negotiator, and **forge's session start**, which has no session to
  operate on. `[webrtc].setup_timeout` bounds ICE/DTLS.

  The leg also gets its **own inactivity watchdog**, wired to the same
  `[media].inactivity_timeout_secs` a SIP call uses. This was found by
  testing rather than reasoning: when a browser tab closes there is no
  BYE and no FIN we can see, and the first working call sat in
  `calls_active` indefinitely after Chrome was killed. Now silence
  reclaims the slot, and the leg logs the frames it moved in each
  direction so a silent call can be told apart from no media at all.
### Added

- **The controller can run a browser call's media** (Phase 2 §4.3 of
  `docs/design/DEV_PLAN_WebRTC.md`). `CallController` now holds a
  `MediaLeg` — `Classic` (forge-engine RTP, every SIP call, unchanged)
  or `WebRtc` (a forge-webrtc peer connection) — instead of a concrete
  tap. The seam turned out to be far narrower than the acceptor's size
  suggests: the controller already exchanged 20 ms PCM16LE frames over
  plain channels and issued `TapCommand`s, and `MediaSession` never
  appeared in it at all, so this is an enum over ~6 methods rather than
  a refactor of the tap's internals.

  `WebRtcTap` drives a browser leg the way `MediaTap` drives a SIP one:
  wait for ICE/DTLS inside `[webrtc].setup_timeout`, then pump SRTP ⇄
  the bridge's frames on a 20 ms tick. What a browser leg **cannot** do
  is refused explicitly rather than half-done — bot-initiated hold and
  park are SIP re-INVITE dances, outbound RFC 2833 DTMF is meaningless
  to a browser, and conference rooms are fed by the classic frame loop.
  Hold's refusal reuses the existing `accepted: false` path (the one
  the conference-hold conflict already uses, #403), so the WS server
  gets a normal `hold_failed` and no new protocol surface appears.
  `Clear`, `Mute`/`Unmute`, `Mark` and the barge-in verdicts all work,
  because those are about this leg's own playout.

  Behaviour for existing calls is unchanged — every SIP call takes the
  `Classic` variant, and the full SIPp suite passes untouched.

### Added

- **Leg selection for browser calls** (Phase 2 §4.1 of
  `docs/design/DEV_PLAN_WebRTC.md`). The acceptor now applies the
  plan's rule — **transport selects eligibility, SDP shape selects the
  leg** — before the SRTP policy gate: an INVITE over WS/WSS carrying a
  WebRTC-shaped offer is recognised as a browser rather than
  mis-diagnosed. Until the media leg is bridged it is still refused
  `488`, but with a message naming the actual problem and telling the
  operator which situation they are in (`[webrtc]` disabled vs enabled
  but not yet terminating media), and under its own metric label
  `siphon_ai_invites_total{result="rejected_webrtc"}` — separately
  alertable, because a browser reaching a daemon that cannot serve it
  is a deployment gap, not routing noise.

  This is the seam the leg plugs into. It matters on its own because
  the Phase 1 browser call hit `488 offer profile UDP/TLS/RTP/SAVPF
  rejected under srtp_mode = Off` — true, and completely misleading.
  Verified with a real headless-Chrome call against both configurations.
  A non-browser RFC 7118 client sending plain RTP still gets the classic
  leg, and a WebRTC-shaped offer arriving over UDP still does too:
  neither half of the rule decides alone.

### Added

- **The WebRTC media leg's lifecycle and audio path** (`crates/webrtc-glue`,
  Phase 2 §4.2–4.3 of `docs/design/DEV_PLAN_WebRTC.md`). `WebRtcLeg`
  answers a browser offer with **complete gathering before the answer**
  (v1 does no SIP trickle, so both ends gather fully before signalling
  — bounded, because a black-holing STUN server must degrade the
  candidate list rather than stall a call), then exposes connect and
  teardown on the `[webrtc].setup_timeout` budget. `InboundAudio` /
  `OutboundAudio` convert between SRTP and the bridge's fixed 20 ms
  PCM16LE frames, reusing the **same** `Reframer` / `pack_pcm16_le` as
  the classic tap so the frame contract cannot drift between leg types,
  and forge-rtp's `JitterBuffer` rather than a bespoke reorder queue.

  Two things this pinned down that the plan had sketched differently:
  **no resampler is needed** — libopus decodes 48 kHz straight to the
  bridge's 16 kHz, exactly as the classic Opus path already relies on,
  so a browser leg hands the bridge the same PCM rate a classic Opus
  call does; and Opus's **RTP timestamp advances at 48 kHz even when
  decoding at 16 kHz** (RFC 7587 §4.1), so a 20 ms frame is 320 samples
  but 960 timestamp units — the increment comes from forge-webrtc's
  `samples_per_20ms()` and never from the PCM length, because deriving
  it from the frame would run the clock at a third of real time and
  slowly starve the browser's jitter buffer.

  Not yet wired into the acceptor — that is the next step, and the
  reason this lands as a self-contained, separately-tested unit.

### Changed

- **forge-media pinned to `v2026.08.25`** (from `v2026.08.24`) —
  forge-rtp 0.3.1, whose `JitterBuffer::pop` no longer aborts the
  process on a reordered RTP packet
  ([forge-media #132](https://github.com/thevoiceguy/forge-media/pull/132)).
  Found building the leg above, which is that buffer's first consumer;
  nothing released used it, so no shipped siphon-ai was affected.

### Added

- **`crates/webrtc-glue` + `[webrtc]` config** — the foundation of
  Phase 2 in `docs/design/DEV_PLAN_WebRTC.md`, and the answer to that
  plan's top risk. The new crate holds the §4.1 leg-selection rule
  (**transport selects eligibility, SDP shape selects the leg**) and
  the `[webrtc]` → forge-webrtc `PeerConfig` mapping. Everything is
  checked against the **real Chrome offer captured off the wire**
  during the Phase 1 browser run — kept verbatim as a fixture — and
  forge-webrtc answers it correctly: right DTLS role for
  `a=setup:actpass`, mirrored `mid`/BUNDLE/`rtcp-mux`, our own
  fingerprint, and exactly one pinned codec (Opus by default; PCMU
  with `prefer_g711`, which makes the whole call 8 kHz end-to-end and
  skips transcoding). Two findings the plan did not anticipate are
  pinned by tests: Chrome's host candidate is an **mDNS `.local`
  name** (a server without an mDNS resolver needs the peer's srflx
  candidate — an ICE failure from this otherwise looks like a network
  problem), and a DTLS-SRTP offer *without* ICE (some SBCs) must stay
  on the classic path, which is why ICE — not the profile — is the
  discriminator. Behind a `webrtc` cargo feature: a plain
  `cargo build` links neither forge-webrtc nor forge-ice, while a
  config that enables it on a binary built without it now fails
  **both at startup and at `siphon-ai check`** (the pre-upgrade gate
  previously only compiled the config, so it could pass where boot
  would fail). No media yet — offer/answer lifecycle and the audio
  path are next.

- **`examples/browser-sip/headless-check.sh` — the Phase 1 exit check,
  self-driving on a browserless box**, and the check itself **passed**
  (recorded in `DEV_PLAN_WebRTC.md` §3.3): headless Chromium + SIP.js
  digest-REGISTERed over WSS with Origin enforcement, the binding
  expired via connection-loss grace when the browser died, the
  exchange appeared in Homer with `Via: SIP/2.0/WSS`, and the
  browser's test INVITE authenticated, routed, and drew the precise
  `488 offer profile UDP/TLS/RTP/SAVPF rejected` that marks Phase 2's
  seam. The script needs no root: system `chromium` if present, else
  Playwright's download with the two missing NSS libraries fetched via
  `apt-get download` into a local dir. The page loads SIP.js as
  jsdelivr's ESM build (npm ships no UMD bundle — the unpkg `dist/`
  path 404s) and gains `?auto=1` / `?auto=1&call=1` self-driving
  modes.

- **`examples/browser-sip/`** — the hands-on Phase 1 exit check for
  `docs/design/DEV_PLAN_WebRTC.md`: a SIP.js test page, a lab config
  (`[sip.wss]` + `[sip.auth]` + `[registrar]` with an Origin
  allow-list), and a mkcert-or-openssl certificate script. Five
  minutes from checkout to a browser tab REGISTERed over WSS, binding
  visible in `siphon_ai_registrar_bindings` and expiring when the tab
  closes; optional Homer leg via `examples/homer-stack/`. The whole
  path short of the browser itself is machine-verified: a WSS probe
  (self-signed TLS, page Origin, digest 401→200→unregister, wrong
  origin refused 403) runs against the exact lab config.
  `ws_sip_call.py` gained a `__main__` guard so the probe logic is
  importable.

- **`[registrar]` — the daemon serves REGISTER** (Phase 1 of
  `docs/design/DEV_PLAN_WebRTC.md` §3.2; distinct from `[[register]]`,
  which is client-side). Thin adapter over upstream `sip-registrar`
  (`BasicRegistrar` does header validation, digest auth with the
  AOR-to-identity authorization step — user A cannot register user
  B's AOR — expires clamping, and the location store); what siphon-ai
  adds is connection awareness: a registration arriving over a stream
  transport (WS/WSS/TCP/TLS) is bound to its connection — the only
  route back to a browser — and expired ~32 s after that connection
  dies (a reload re-REGISTERs inside the window). Off by default;
  enabling it without `[sip.auth]` is a load error unless the
  explicit `allow_unauthenticated` lab flag is set. Credentials are
  `[sip.auth]`'s users, so INVITE and REGISTER share one set. New
  metrics: `siphon_ai_registrar_registers_total{result}` (alert on
  `forbidden` — someone's credentials claiming someone else's
  identity) and `siphon_ai_registrar_bindings`. The suite gains a
  `ws_registrar` phase driving the SIP.js-shaped flow over WS:
  REGISTER → 401 digest challenge → authenticated REGISTER → 200 →
  `Expires: 0` unregister. siphon-rs pinned to `v2026.08.25`
  (sip-uas 0.5.0: `on_register` now receives the `TransportContext`,
  [siphon-rs #127](https://github.com/thevoiceguy/siphon-rs/pull/127)).

- **SIP over WebSocket listeners (RFC 7118)** — Phase 1 of
  `docs/design/DEV_PLAN_WebRTC.md`. `[sip].transports` accepts `"ws"`
  and `"wss"`, each with its own listener block: `[sip.ws]` /
  `[sip.wss]` (`listen`, `cert`/`key` for WSS, `allowed_origins`).
  Upgrades must offer the `sip` subprotocol; a non-empty
  `allowed_origins` refuses unlisted (or absent) `Origin`s `403` at
  the upgrade, pinning which pages may open signalling (browsers
  always send it; `[sip.auth]` remains the real authentication).
  Replies and in-dialog requests to a WS client route down the
  connection the client established — browser Via/Contacts are
  unroutable, so there is deliberately no dial-back fallback. Off
  unless configured; the WSS cert is deliberately independent of
  `[sip.tls]` (browsers must trust it) and rotating it wants a
  restart. The signalling suite gains a `ws_signaling` phase
  (`ws_sip_call.py` — SIPp can't speak RFC 7118): a full
  INVITE/ACK/BYE call over one WS connection, origin-refusal
  assertions, and the settle-to-zero check. Verified end-to-end:
  OPTIONS and a full call answered over WS, wrong/absent origin
  refused, teardown clean. siphon-rs pinned to `v2026.08.24.1`
  (sip-transport 0.5.0: `WsAcceptPolicy` + WS idle timeout,
  [siphon-rs #125](https://github.com/thevoiceguy/siphon-rs/pull/125));
  the `ws` feature joins `tls` on the sip-transport dependency.

- **RTP port-pool gauges + a teardown-soak harness phase** (Phase 0 of
  `docs/design/DEV_PLAN_WebRTC.md`). Two new gauges,
  `siphon_ai_rtp_port_pairs_allocated` and `_capacity`, published every
  2 s by a sampler that reads the pool itself — deliberately not
  incremented at alloc/free sites, since a site-updated gauge
  under-counts under exactly the leak it exists to catch. Allocated
  diverging from `siphon_ai_calls_active` is a leaked media session;
  alert on it. The SIPp suite gains a `teardown_soak` phase: twelve
  calls across clean-BYE / CANCEL / abrupt-vanish (established dialog,
  peer exits with no BYE — the browser-shaped teardown) over UDP and
  TCP, then an assertion that live calls, the dialog store, and
  allocated port pairs all return to zero. Lab result on this baseline:
  eleven distinct teardown shapes (including delayed-offer and no-ACK
  vanishes, and with the inactivity watchdog disabled) all settle to
  zero — teardown is currently defense-in-depth (media watchdog,
  forge's own session sweep, dialog reaper, BYE-failure-tolerant
  teardown), and the phase exists to keep it that way once a WebRTC
  leg multiplies the abrupt-teardown rate.

### Changed

- **forge-media pinned by tag: `v2026.08.24`** (was `rev = "c277860cd123"`) —
  forge-media's first tagged release under its new
  [RELEASING.md](https://github.com/thevoiceguy/forge-media/blob/main/RELEASING.md)
  (the siphon-rs model, adopted in
  [forge-media #131](https://github.com/thevoiceguy/forge-media/pull/131)).
  Both upstream pins now read as tags, and this pair is self-consistent by
  construction: forge-media `v2026.08.24` embeds siphon-rs `v2026.08.24`,
  the same tag `[workspace.dependencies]` pins directly. Content over the
  old rev: forge-webrtc 0.4.0's G.711 negotiation
  ([forge-media #130](https://github.com/thevoiceguy/forge-media/pull/130))
  and crate-version stamps (forge-ice 0.3.0, forge-rtp 0.3.0) — none of
  which siphon-ai compiles today (forge-webrtc is not yet a dependency;
  the version bumps are manifest-only for the crates we use), so no
  behaviour change.

### Fixed

- **`examples/browser-sip/gen-cert.sh` works on a fresh clone** (#587) — it
  assumed state a previous run left behind; the lab's first-time path now
  generates the browser-trusted cert without it.

---

**Artifacts**

- Static musl binaries for `x86_64` and `aarch64` (`.tar.gz`).
- Debian packages (`.deb`) for `amd64` and `arm64` — `apt install ./siphon-ai_*_amd64.deb`.
- `SHA256SUMS` — verify with `sha256sum -c SHA256SUMS`.
- `*-sbom.cdx.json` — CycloneDX SBOM.
- `SHA256SUMS.cosign.bundle` — cosign keyless signature over the checksums.
- Container: `ghcr.io/thevoiceguy/siphon-ai:v0.51.0` (multi-arch, cosign-signed).

Verify the checksums signature:
```
cosign verify-blob --bundle SHA256SUMS.cosign.bundle \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  --certificate-identity-regexp 'release.yml@' SHA256SUMS
```

Related Repositories