v0.48.9

thevoiceguy/siphon-aiv0.48.9Aug 8, 2026by github-actions[bot]

Upgrading?

AI Summary

Fixes CDR stamping for calls ending during consent announcements and prevents phantom `blocked` metrics for on-demand calls with broken prompts.

Key Highlights

  • Fixes multiple teardown-edge gaps in consent stamping
  • `mode = "on_demand"` no longer stamps `blocked` for every call with a broken prompt
  • `recording_result` is now a typed enum end-to-end for schema safety

Full Release Notes

### Fixed

- **A call that ends during its consent announcement now stamps a truthful CDR** (issue #444). The 0.48.8 stamping machinery had four teardown-edge gaps, all in the same resolution path. (1) The common one: a `mode = "always"` caller hanging up mid-prompt broke the controller's biased select before the announce-completion arm could run, and nothing after the loop read it — the CDR serialized with no consent block, no `recording_result`, and no metric, indistinguishable from a call never subject to recording, which is precisely the gap #440 set out to close. The consent stamp is now driven by *an announcement was configured* rather than *the prompt played or failed*, and teardown drains the completion channel — so an abandoned-mid-prompt call reads `consent { announced: false }` with no `recording_result`: recording correctly never started, and the consent block alone tells the story. (2) A WS-failure prompt (`on_ws_failure = "play_prompt"`) preempting an in-flight consent announcement dropped the old completion sender, which the controller read as a play failure and stamped `blocked` — so every call in a WS outage window claimed a broken prompt file and sent the on-call chasing config. The tap now resolves a replaced announcement as `Preempted` (a typed `AnnounceEnd` replaces the raw milliseconds on the completion channel), which is not `blocked`: the outage calls stamp `announced: false` and no result. A dropped completion channel (tap death) likewise no longer claims `blocked`. (3) An idle recording writer that missed the 500 ms finalize deadline or panicked fabricated a `Failed` summary whose `recording_path` named a file that was never created — routing a never-started recording into the `failed`-means-disk alert bucket; the fabrication is now gated on capture having actually been requested. (4) A controller crash (sub-task panic) after the announcement had fail-closed recording lost the `blocked` signal entirely on the error-path CDR; `CallError::TaskJoin` now carries the fail-close flag and consent story out, so the abnormal-teardown population — where the audit trail matters most — keeps it.

- **`mode = "on_demand"`: a broken consent prompt no longer stamps `blocked` on every call** (issue #446). A per-call announcement failure unconditionally fail-closed recording — correct — but also stamped `recording_result = "blocked"` and ticked `siphon_ai_recordings_total{result="blocked"}` on **every** call, including the majority whose server would never have requested recording: a fleet recording ~5% of calls on demand with a bad prompt pushed reported a ~20× phantom recording shortfall. The failure is now remembered per-call and escalates to `blocked` only if the server actually sends `start_recording` (which is refused, keeping the fail-close); calls nobody asked to record log the WARN, stamp `consent { announced: false }`, and report no `recording_result` — there is no recording outcome to report. `mode = "always"` is unchanged. Documented in DEPLOY.md and RECORDING.md.

### Changed

- **`recording_result` is a typed enum end to end** (issue #447). The CDR schema field was a stringly `Option<String>` with a closed four-value vocabulary enforced nowhere; it is now `Option<RecordingResult>`, a serde enum with pinned snake_case wire strings — the same mechanism as `TerminationCause` — so the vocabulary is enforced at the schema layer and a new variant is a schema-version conversation instead of a silently absorbed string. Wire output (JSON and CSV) is byte-identical. Alongside it, the file-wins-over-blocked precedence rule now lives in exactly one place (`CallOutcome::recording_result`, resolved once into the termination view) instead of two byte-identical copies of which only the uncalled one was tested.

---

**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.48.9` (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