v0.40.0
thevoiceguy/siphon-aiv0.40.0Jul 23, 2026by github-actions[bot]
Upgrading?
Release notes summary
Adds `answered_at` to CDR records for accurate connected duration calculation and fixes CDR termination cause attribution for remote hangups.
Key Highlights
- CDR schema v5: Added `answered_at` field for accurate connected duration.
- CDR `termination.cause` now correctly records `caller_hangup` for remote BYE.
- Config warnings now appear when sub-sinks are enabled under a disabled master switch.
- Fixed `rx_packets_lost` no longer reporting ring duration as packet loss.
Breaking Changes
- CDR schema version bumped to v5.
New Features
- Added `answered_at` to CDR schema.
Full Release Notes
### Added
- **CDR `answered_at` — connected duration is now derivable** (issue #331; **CDR schema v4 → v5**). A record carried `started_at`, `ended_at` and `duration_ms` but no answer timestamp, and for outbound calls `started_at` is stamped when the origination request is accepted — *before the INVITE goes out*. So `duration_ms` silently measured INVITE-to-end including ring time, and connected duration was not derivable from a v4 record in any form. The overstatement is exactly the ring duration, so it is unbounded and varies per call: ~1.4 s on an instant pickup, ~12.4 s on a call that rang that long — more than half again on a 21 s conversation. Carriers bill from answer, so anyone rating or reconciling against an invoice was systematically over by an amount correlated with how long each call rang, with nothing in the record signalling it. Unanswered calls were also indistinguishable from very short answered ones on duration alone.
`answered_at` is stamped where `place()` / `place_delayed()` return on the 2xx for outbound; for inbound it coincides with `started_at`, because that is stamped in `run_call`, which is only reached once the INVITE has been accepted. `None` when the call never connected. `duration_ms` keeps its existing meaning — wall-clock including setup — now documented as such rather than left ambiguous; billable duration is `ended_at - answered_at`. CSV gains an appended `answered_at` column (49 total).
### Fixed
- **CDR `termination.cause` is `caller_hangup` when the far end hangs up** (issue #332; **CDR schema v4 → v5**, shared with the entry above). A remote BYE recorded `local_shutdown` — the same value as an admin force-hangup, a CANCEL, and an RFC 4028 session expiry — so the single most common way a call ends had no distinct value. The daemon was not missing the information: it computes the correct attribution two lines earlier in the same match arm, sends the WS server `caller_hangup`, and then did not consult `remote_bye_received()` when setting the CDR cause. The WS protocol and the CDR therefore disagreed about the same event, and the CDR — the billing-grade, durable side — was the lossy one. Anyone building "who hung up first" analytics (abandonment rates, agent-vs-customer disconnect, short-call diagnosis) could not do it from CDRs at all.
New `CallTermination::CallerHangup` / `TerminationCause::CallerHangup`, serialising as `caller_hangup` to match the WS `stop` message's existing `reason` for the same event, gated on the `remote_bye_received()` predicate already in scope. `local_shutdown` now means what its name says: admin force-hangup, CANCEL, session-timer expiry. The `siphon_ai_calls_total{cause}` metric gains the same value, so a dashboard split by cause will show remote hangups moving out of `local_shutdown` rather than the total changing.
Two integration tests in `registry_bye.rs` had codified the old behaviour (asserting a BYE yields `LocalShutdown`); they now assert the corrected attribution.
**CDR version bumped once for both changes.** `answered_at` alone is additive-optional and would not have required it (the `verstat_attest` / `recording_id` precedent), but a new `TerminationCause` value can break a strict exhaustive matcher — the same reasoning that drove v2 and v3. Consumers should gate on `version >= 5`.
- **A sink enabled under a disabled master switch now warns at startup instead of failing silently** (issue #333). `[cdr.file].enabled = true` without `[cdr].enabled = true` produced no CDRs, no warning, and no log line of any kind — the master switch installs a no-op sink regardless of the sub-blocks, and nothing surfaced the contradiction. A production box ran in exactly this state for the lifetime of a test engagement: zero CDRs written, `/var/log/siphon-ai/` present and writable and empty, `journalctl | grep -i cdr` returning nothing at all. It was found only by going looking for a record that should have existed, and it silently invalidated everything downstream — any test assuming CDRs exist passes vacuously, so the CSV format added in 0.36.0 and the `tx_*` quality fields had never once been written on that box.
Config load now logs one `warn!` per affected sub-sink, naming it: `[cdr.file].enabled = true but [cdr].enabled = false — this sink is inert and nothing will be written`. A warning rather than a hard error, because silencing a whole subsystem with one flag while leaving the sub-blocks configured is legitimate; CLAUDE.md §4.6's fail-loud rule is about config that *cannot* work, not config that deliberately does nothing.
The issue asked whether other master-switch/sub-block pairs share the shape. `[conference]`, `[park]` and `[hep]` do not — they have no sub-blocks with their own `enabled`. **`[audit]` and `[quality]` do**, each with `.file` and `.webhook` under a master switch, and both had the identical silent failure; all three are covered. Documented in `docs/CONFIG.md`, including the trap that omitting the `[cdr]` block entirely leaves the master off (`enabled` defaults to `false`), so a lone `[cdr.file]` block writes nothing.
- **`rx_packets_lost` no longer reports the ring duration as packet loss** (issue #330; fixed upstream by [forge-media#94](https://github.com/thevoiceguy/forge-media/pull/94), pin bumped `f6151edf2724` → `24e20ae1e8ac`). On a trunk that sends ringback as early media, `rx_packets_lost` was set once at answer to `ring_seconds × 50` and never moved again — 621 on a 12.42 s ring, 127 on a 2.5 s ring — which pinned the transport `mos_estimate` to **1.0, the floor of the scale**, for the whole call. The effect was inverted from reality (the longer a call rang, the worse its reported quality, so the calls that rang longest looked worst to anyone triaging complaints) and it propagated into the CDR `quality` block and Homer's HEP QoS, contaminating historical quality data and any MOS-based alerting at the source. `avg/max_packet_loss_ratio` stayed correctly at `0.0` throughout, so the two loss signals in the same record actively disagreed.
Root cause was in forge's `RxStreamStats`, which carried a single sequence baseline with no SSRC field at all. The trunk switches SSRC at the answer while keeping the sequence counter continuous, so a baseline anchored in the ringback era survived into the answered stream and charged the whole ring against it. Diagnosed from a production capture (4 SSRCs on one 5-tuple, seq 773→3333, 2561 packets, zero wire loss) and pinned upstream by a regression test that replays those real sequence numbers — 748 phantom lost with the fix disabled, matching the capture's 14.96 s ring × 50, and 0 with it. No siphon-ai code change; the counters this daemon already reads simply stop being wrong.
Still open upstream as [forge-media#95](https://github.com/thevoiceguy/forge-media/issues/95): the early-media packets are received but never counted at all, so `rx_packets_received` continues to undercount by the ringback burst, and a trunk that keeps one SSRC across the answer would still mis-report loss.
---
**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.40.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
A cross-platform desktop All-in-One assistant for Claude Code, Codex, OpenCode, OpenClaw, Grok Build & Hermes Agent. Only official website: ccswitch.io
An open-source remote desktop application designed for self-hosting, as an alternative to TeamViewer.
Build smaller, faster, and more secure desktop and mobile applications with a web frontend.
π RuView turns commodity WiFi signals into real-time spatial intelligence, vital sign monitoring, and presence detection — all without a single pixel of video.
Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one