v0.50.0

OpenMOSS/MOSS-Transcribe-Diarizev0.50.0Aug 23, 2026by github-actions[bot]

Upgrading?

AI Summary

This release introduces a new configuration key to reserve RTP ports for outbound calls, preventing inbound surges from starving origination. It also bumps forge-media, which changes the DTLS-SRTP certificate type from RSA-2048 to ECDSA P-256.

Key Highlights

  • New config `[media].reserved_outbound_calls` to prioritize outbound calls.
  • forge-media bump changes DTLS-SRTP certificate from RSA-2048 to ECDSA P-256.
  • New metrics for RTP reserve blocks and reserved calls.
  • Exact floor for RTP reservation implemented in the allocator.

New Features

  • RTP port reservation for outbound calls to prevent starvation.
  • DTLS-SRTP certificate type change to ECDSA P-256.
  • New metrics `siphon_ai_rtp_reserve_blocks_total` and `siphon_ai_rtp_reserved_outbound_calls`.

Full Release Notes

**One new config key and one dependency bump that reaches the wire.**

`[media].reserved_outbound_calls` closes the last gap the load plan found:
inbound and outbound calls draw RTP ports from one pool, first-come-first-served,
so on a node that both answers and originates an inbound surge could starve
origination *completely* with no inbound-side symptom. Set it and the inbound
allocator stops at a floor, leaving the rest to origination. Default `0` is the
old unreserved behaviour, so **upgrading changes nothing until you set it**.

⚠️ **DTLS-SRTP deployments read the forge-media bump below before upgrading.**
The certificate moves from RSA-2048 to ECDSA P-256, which is live wherever
`[media].srtp_offer = "dtls"` is set. Almost every peer prefers P-256 already —
it is what browsers present — but a peer that can only do RSA key exchange
would newly fail.

**No protocol change** (still `1`), CDR still v8. Adds one config key, two
metrics, and a `--inspect-config` line. Wants a restart to take effect.

### Changed

- **forge-media pinned to `1fe94a502efa`** — nine commits, three of them
  filed from this project. [#120](https://github.com/thevoiceguy/forge-media/pull/120)
  adds `PortPool::allocate_reserving(min_free)` and
  `MediaSessionConfig::min_free_port_pairs`, which is what makes
  `[media].reserved_outbound_calls` above an *exact* floor: the check now
  happens inside the allocator's own critical section instead of as a
  free-pair read taken just before it, so concurrent INVITE setup can no
  longer dip below the reservation. Both land in the same release, so no
  published build ever had the softer version.
  [#122](https://github.com/thevoiceguy/forge-media/pull/122) fixed
  forge's broken-link CI step (it grepped for a word rustdoc never
  prints, so it had passed unconditionally while eleven unresolved links
  accumulated) and [#123](https://github.com/thevoiceguy/forge-media/pull/123)
  gave `forge-vad` a types-only `forge-core` dependency so two of them
  could be live links. Neither changes anything we compile.

  **One upstream change reaches our wire without us asking for it:**
  forge-media #117 + #118 move the DTLS-SRTP certificate from **RSA-2048
  to ECDSA P-256** and offer ECDHE-ECDSA first. We build `forge-engine`
  with `dtls`, so this is live wherever `[media].srtp_offer = "dtls"`
  (0.9.4) is set. The SDP `a=fingerprint` stays `sha-256` — only the key
  type, and therefore the hash value, change — and P-256 is what browsers
  and webrtc-rs present, so it is the more interoperable default as well
  as ~100× faster to generate. A peer that can only do RSA key exchange
  would now fail where it previously worked. The `delayed_offer_dtls`
  SIPp scenario covers the handshake.

  The remaining commits (#116, #119, #121) are WebRTC/ICE work in
  `forge-webrtc` / `forge-ice`, neither of which we depend on. #115 bumps
  forge-media's own siphon-rs submodule to v2026.08.19; we pin siphon-rs
  directly at v2026.08.21 and the two stay separate package sources, but
  the version skew closed — forge's vendored `sip-sdp` goes 0.3.0 → 0.3.1,
  matching what our tag carries.

### Added

- **`[media].reserved_outbound_calls`: hold RTP ports back for
  origination** (#556). Inbound and outbound draw port pairs from one
  pool, first-come-first-served, with no reservation between them, so on
  a node that both answers and originates an inbound surge starves
  origination *completely* — and there is no inbound-side symptom while
  it happens. Measured on 0.49.9 with the pool shrunk to 60 calls and 50
  inbound + 20 outbound asked for: inbound established **50/50** and
  stayed healthy for the whole window while **10 of 20 originates
  failed** (`test-harness/load/RESULTS-0.49.9-mixed-and-soak.md` §2). The
  starved direction is usually the one with a deadline attached — a
  scheduled callback or a notification, versus a caller who will redial —
  so the default allocation is the opposite of how an operator would
  prioritise it, and the only lever was `[sip.admission]`: a global
  inbound concurrency cap standing in for a pool reservation, with the
  two numbers drifting apart the moment `rtp_port_range` changed.

  Set `reserved_outbound_calls = N` and the **inbound** allocator refuses
  once the pool's free pairs reach `N`, leaving the rest to origination,
  which is not gated. Counted in calls, not ports (one call is one pair).
  The refusal reuses the exhausted-pool path from #554/#555 unchanged —
  `503 Service Unavailable` + `Retry-After`, counted as
  `siphon_ai_invites_total{result="rejected_capacity"}` — deliberately:
  a caller must not be able to tell "we are empty" from "the rest is
  spoken for", since both mean try another node. The split is
  operator-side, on the new **`siphon_ai_rtp_reserve_blocks_total`**
  (published as a zero baseline, so "the reserve has never had to shed"
  reads as `0` rather than as a missing series) alongside
  **`siphon_ai_rtp_reserved_outbound_calls`**, the configured threshold as
  a gauge. Startup logs the split (`pool_calls` / `reserved_outbound_calls`
  / `inbound_calls`) and `--inspect-config` prints it.

  Default `0` — unreserved, byte-for-byte the pre-0.50 behaviour.
  Restart-required. A value at or above the pool's capacity fails at load
  with the capacity in the message: reserving the whole pool would answer
  `503` to every INVITE forever, which is a concurrency cap wearing a
  media knob's clothes. **The floor is exact** — it is evaluated inside
  the RTP pool allocator's own critical section, so no amount of
  concurrent INVITE setup can dip below it and `N` needs no slack (that
  took an upstream change; see the forge-media bump below). The one
  caveat, in `docs/CONFIG.md`: the knob decides *who loses* when the pool
  is too small, it does not make it bigger. `rtp_port_range` must still
  be sized for the sum of both directions; `docs/DEPLOY.md` now says so
  under its own heading, with the measurement and the reason an operator
  watching HTTP status codes sees none of it (`POST /admin/v1/calls`
  answers `202` and the port failure arrives later on the
  `outbound_failed` webhook).

- **The SIPp harness asserts the outbound delayed-offer counter, not just
  "answered"** (#558). The `outbound_delayed_uas` phase drives a full
  RFC 3264 delayed offer — offerless INVITE out, the peer's offer in its
  2xx, our answer in the ACK — but asserted only
  `siphon_ai_outbound_calls_total{result="answered"}`, which *any*
  originate satisfies. `siphon_ai_outbound_delayed_offer_total` (#406) is
  the counter that says the negotiation itself succeeded, and nothing in
  CI asserted it, so its documented result set could rot unnoticed. The
  scenario's own `check_it` proves the ACK carried an m-line; this adds
  the daemon's side of the same claim, and catches an originate that
  answers while the negotiation lands on `missing_sdp_offer`,
  `invalid_remote_media`, `media_activate` or an `srtp_*` label.

---

**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.50.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