Summary
CallSession.record() and CallSession.send_dtmf() fail closed when the session is stale or inactive, but the playback helpers (play, play_sequence, and play_loop) do not check self.is_active before writing to the audio pipeline. A stale CallSession object can therefore enqueue audio onto the current audio bridge after another call has become active.
For PBX/IVR use this is a privacy and call-routing boundary issue: a handler holding an old session should not be able to play prompts/hold music into a different caller's live audio path.
Evidence
Baseline gates are healthy on main at bb90684c0957c286dc901cef829f498c2c9418e1:
$ git diff --check
# exit 0
$ PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/ -q
587 passed in 5.97s
Affected code:
callstack/voice/service.py:279-281 defines CallSession.is_active as both FSM ACTIVE and service.active_call is self.
callstack/voice/service.py:300-309 applies that guard before recording.
callstack/voice/service.py:420-445 applies that guard before sending each DTMF digit.
callstack/voice/service.py:288-298 forwards play, play_sequence, and play_loop directly to self.service._audio without the same guard.
No-hardware repro:
PYTHONPATH=. uv run --no-project --with pyserial-asyncio python /tmp/callstack_probe_stale_playback.py
Probe output:
stale_is_active_before: False
audio_calls_from_stale_session: [('play_file', 'prompt.wav', False), ('play_sequence', ('a.wav', 'b.wav'), False), ('play_loop', 'hold.wav', True)]
The stale session reports is_active == False, but all three playback methods still invoke the audio layer.
Duplicate check
No existing issue or PR matched these targeted searches:
gh issue list --repo Justinabox/Callstack --state all --search 'stale session playback active_call in:title,body' --limit 20
gh issue list --repo Justinabox/Callstack --state all --search 'CallSession play stale active call audio in:title,body' --limit 20
gh pr list --repo Justinabox/Callstack --state all --search 'CallSession play stale session audio' --limit 20
Issue #41 tracks voicemail features, but it does not cover stale CallSession playback guards.
Expected behavior
All call-audio operations exposed through CallSession should fail closed unless that exact session is still the active call.
Suggested fix direction
Add the same if not self.is_active: raise RuntimeError("Cannot play audio without an active call") style guard to CallSession.play, play_sequence, and play_loop before touching service._audio. Keep AudioPipeline usable directly for lower-level tests if desired; the session boundary is the important public guard.
Acceptance criteria
- A stale or inactive
CallSession.play(...) raises before calling AudioPipeline.play_file.
- A stale or inactive
CallSession.play_sequence(...) raises before calling AudioPipeline.play_sequence.
- A stale or inactive
CallSession.play_loop(...) raises before calling AudioPipeline.play_loop.
- Existing active-call playback, record, and DTMF tests remain green.
Verification gates
git diff --check
PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/test_call_service.py tests/test_call_session_play_and_collect.py -q
PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/ -q
Summary
CallSession.record()andCallSession.send_dtmf()fail closed when the session is stale or inactive, but the playback helpers (play,play_sequence, andplay_loop) do not checkself.is_activebefore writing to the audio pipeline. A staleCallSessionobject can therefore enqueue audio onto the current audio bridge after another call has become active.For PBX/IVR use this is a privacy and call-routing boundary issue: a handler holding an old session should not be able to play prompts/hold music into a different caller's live audio path.
Evidence
Baseline gates are healthy on
mainatbb90684c0957c286dc901cef829f498c2c9418e1:Affected code:
callstack/voice/service.py:279-281definesCallSession.is_activeas both FSMACTIVEandservice.active_call is self.callstack/voice/service.py:300-309applies that guard before recording.callstack/voice/service.py:420-445applies that guard before sending each DTMF digit.callstack/voice/service.py:288-298forwardsplay,play_sequence, andplay_loopdirectly toself.service._audiowithout the same guard.No-hardware repro:
Probe output:
The stale session reports
is_active == False, but all three playback methods still invoke the audio layer.Duplicate check
No existing issue or PR matched these targeted searches:
Issue #41 tracks voicemail features, but it does not cover stale
CallSessionplayback guards.Expected behavior
All call-audio operations exposed through
CallSessionshould fail closed unless that exact session is still the active call.Suggested fix direction
Add the same
if not self.is_active: raise RuntimeError("Cannot play audio without an active call")style guard toCallSession.play,play_sequence, andplay_loopbefore touchingservice._audio. KeepAudioPipelineusable directly for lower-level tests if desired; the session boundary is the important public guard.Acceptance criteria
CallSession.play(...)raises before callingAudioPipeline.play_file.CallSession.play_sequence(...)raises before callingAudioPipeline.play_sequence.CallSession.play_loop(...)raises before callingAudioPipeline.play_loop.Verification gates