v0.39.0

thevoiceguy/siphon-aiv0.39.0Jul 22, 2026by github-actions[bot]

Upgrading?

AI Summary

Fixes admin metrics for the drain endpoint and enables proper teardown for outbound calls receiving a far-end BYE.

Key Highlights

  • `GET /admin/v1/drain` is now properly labelled in `siphon_ai_admin_requests_total`.
  • Far-end BYE now correctly tears down outbound calls (fixes #324).
  • Outbound legs now registered in `CallRegistry` for BYE handling.
  • Fixed documentation for `rtp_timeout` config key.

Full Release Notes

### Fixed

- **`GET /admin/v1/drain` is now labelled in `siphon_ai_admin_requests_total`** (issue #319). The route is dispatched and served correctly but had no arm in `route_label()`, so it fell through to `endpoint="unknown"` — the only served admin route missing from the table. Two consequences: successful drain polls were indistinguishable from unrecognised paths (both `endpoint="unknown"`, separated only by `result`), polluting the reasonable "someone is probing the admin API" signal a dashboard keys on `endpoint="unknown"`; and drain — the endpoint a deploy script polls hardest during a rollout, likely the highest-rate admin request on the box — was the one route with no per-endpoint visibility. Metric-label only; no functional, auth, RBAC, or response-body change.

  While fixing it, `min_role()` turned out to be missing the same route. That table's doc requires it to mirror `admin::dispatch`, and without an arm the drain path only reached `ReadOnly` by falling through to `authorize`'s unknown-path default — the right answer (and the one `docs/DEPLOY.md` already documents) for the wrong reason, and silently wrong had that default ever been tightened. Both tables now carry the route, so behaviour is unchanged and no longer accidental.

  New `every_served_route_has_a_bounded_label` test walks all ten static dispatcher routes plus the nine dynamic templates and asserts each has a non-`"unknown"` label, that a static route's label is exactly `"METHOD /path"` (catching a copy-pasted arm pointing at the wrong template), that a dynamic route's label is a template rather than a concrete path and leaks no id into the metric label, and that every served route has an explicit `min_role` arm. The prior `route_label` coverage was only the two `:name` registration templates.

- **`PROTOCOL.md` §3.10 cited a config key that does not exist** (issue #325). The `rtp_timeout` row named `media.rtp_idle_timeout_ms` as the governing setting — a key that appears nowhere else in the repo, in no `.rs`, `.toml`, or other doc. The real control is `[media].inactivity_timeout_secs`: different name, different unit. This is the worst failure mode for a config reference — an operator reading the protocol spec adds the key, sees no effect, and concludes the timeout is unconfigurable or that they've disabled it when they haven't. Per CLAUDE.md §9 `PROTOCOL.md` is the highest-stakes document in the repo, and §3.10 is the section a WS-server author consults when calls die on a silent leg. The row now names the real key and adds what a server author actually needs: default `60` s, per-route overridable via `[route.media].inactivity_timeout_secs`, `0` disables the watchdog, and the error is fatal with `stop` (`reason: "error"`) following. The §3.10 example `message` was also fictional — `"no RTP for 30s on leg A"` implies a 30 s default the daemon doesn't have and a per-leg attribution it never produces, with "leg A" appearing nowhere else in the spec; it now carries the emitted text, `"no inbound RTP within the inactivity timeout"`. Docs only — no behaviour change. Found while tracing the teardown in #324.

- **A far-end BYE now tears down an outbound call** (issue #324; requires the siphon-rs pin bump to `6fd432270bb8` = [siphon-rs#67](https://github.com/thevoiceguy/siphon-rs/pull/67)). Previously a BYE from the remote party on an **outbound** call was answered `481 Call/Transaction Does Not Exist` and never reached the call controller. The call then lingered until the 60 s media-inactivity watchdog, producing a CDR duration inflated by a full minute (billing-grade: ~144 s recorded against a carrier-billed 84 s in the reported case), a `tap_ended` termination cause that misclassifies a normal hangup as a media failure, a trunk channel held for the extra minute, and a doomed teardown BYE that burned another ~32 s on Timer F. It fired on every outbound call where the remote party hung up first — the common case.

  There were **two independent causes**, and the issue diagnosed only the first:

  1. **The dialog store wasn't shared.** Each gateway UAC owned a private `DialogManager`, so `IntegratedUAS::dispatch` resolved the inbound in-dialog request against a different store, missed, and short-circuited to 481 without ever invoking `on_bye`. Fixed by threading `uas.dialog_manager()` into every gateway UAC via the new upstream `IntegratedUACBuilder::dialog_manager` setter. (The inbound path has always shared this store; the outbound path is catching up.)

  2. **Outbound calls were never in the registry the BYE handler resolves against.** Even once `on_bye` ran, `terminate_from_bye(sip_call_id)` missed: `dispatch_bye` consults `CallRegistry` (keyed by SIP Call-ID), while the outbound service only ever inserted into `CallControlRegistry` (keyed by bridge id). The issue asserted this half was already fine because the call "is listed in `GET /admin/calls`" — but that endpoint reads the *control* registry, a different table. Outbound legs now join `CallRegistry` for their lifetime, and the teardown BYE is gated on `remote_bye_received()` the way the inbound acceptor has always gated its own, so we no longer BYE into a dialog the peer has already discarded.

  **Secondary behaviour change:** `CallRegistry` is also the daemon's active-call count for graceful shutdown, so `/admin/v1/drain`'s `active_calls` now includes outbound calls and a drain waits for them to finish. Previously a deploy walked straight past in-flight outbound calls. This is a deliberate fix, but it changes a number operators may alert on.

  A peer re-INVITE on an outbound leg is now refused `501` (the documented "no stored answer" path) rather than the `481` an unregistered dialog produced — renegotiating an outbound leg is genuinely unimplemented, so 501 is the honest response.

  New SIPp scenario `outbound_remote_bye` closes the coverage gap the issue flagged: roles inverted, SIPp answers the originated call and then sends the BYE. It asserts the `200` (a regressed daemon answers `481` there), that the daemon logged `BYE → controller shutdown` (proving the lookup resolved), and that no Timer-F teardown failure followed. Verified to fail with *either* half of the fix reverted — checking only that the call left the registry is too weak, because the bridge notices the peer is gone and ends the call anyway within seconds, which is precisely how the second cause hid behind the first. This is the suite's first UAS-side in-dialog request; the harness suite is now 38 scenarios.

---

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