Files
hold-slayer/docs/deployment-validation-plan.md
Robert Helewka e9219f2d4a
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 58s
CVE Scan & Docker Build / build-and-push (push) Successful in 1m42s
docs: mark PJSUA2 build complete and add build procedure
Update deployment validation plan to reflect Phase 1b completion — pjsua2
built from pjproject 2.17 on caliban without sudo. Document the non-obvious
RPATH/patchelf step, the status() method name correction, and two remaining
caveats (not captured by pip install, Docker still runs stub media).

Update README to point at the new docs/pjsua2-build.md and clarify stub-mode
behavior.
2026-07-28 22:05:26 -04:00

344 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Hold Slayer — Deployment & Validation Plan
Bring the gateway up in stages, proving each layer before the one above it can
lie about working. The ordering principle: **nothing touches the PSTN until
everything that can be validated without it has been.** A bug found on the trunk
costs money and rings a stranger's phone; the same bug found internally costs
nothing.
Phases gate on each other. Do not start a phase until its predecessor's exit
criteria are all green.
---
## Environment as found (2026-07-28)
Verified on `caliban`, not assumed:
| Fact | State | Consequence |
|---|---|---|
| `sippy` 2.3.0 | installed | SIP signaling is real |
| `pjsua2` | ✅ **built 2026-07-28** (pjproject 2.17) | real media pipeline; see [pjsua2-build.md](pjsua2-build.md) |
| `USE_MOCK_SIP` | `true` | engine is `MockSIPEngine`; `/health` can never be `healthy` |
| `SIP_TRUNK_*` | placeholders (`sip.yourprovider.com`) | no trunk configured |
| `SIP_TRUNK_DID` | `+16472474242` | real DID already allocated |
| `GATEWAY_SIP_PORT` | `5060` | `.env.example` says 5080 — reconcile |
| `DATABASE_URL` | `portia.incus:5432/hold_slayer` | remote Postgres, reachable? unverified |
| Casdoor | enabled, `hold-slayer` app, `OWNER_NAME=r@helu.ca` | SSO path is configured |
| `SPEACHES_URL` | `pan.helu.ca:22070` | not reachable from this host as tested |
| `LLM_BASE_URL` | `nyx.helu.ca:29000` | not reachable from this host as tested |
| `TTS_*` | unset → defaults to `localhost:8000` | **collides with the app's own port** — must be set |
| `pytest` | 159 pass, 6 fail | 4 are `.env` bleed, 2 are real (see Phase 0) |
Two config landmines worth fixing before anything else: `TTS_BASE_URL` is unset
and defaults to `http://localhost:8000`, which is Hold Slayer's own port — TTS
calls would loop back into the gateway. And `API_TOKEN` still sits in `.env`
though the code now uses Casdoor + PATs; it is dead weight that suggests the old
auth path still exists.
---
## Phase 0 — Baseline: make the test suite an honest gate ✅ COMPLETE
**Done 2026-07-28.** Suite is 165 passed / 0 failed from the repo root with the
real `.env` present. Outcomes differed from the plan's guesses in one important
way: the classifier failure was a **real bug in `classify_chunk`**, not a
threshold or fixture problem — see item 3. One item is deferred with rationale
(`ruff`, below).
You cannot use a suite with known failures as a regression gate; every later
phase's "did I break something" check depends on a clean baseline.
**Do:**
1. Fix the `.env` bleed in `tests/test_oauth_metadata.py`. The 4 failures are
entirely because pydantic reads the real `.env` and `PUBLIC_BASE_URL=http://localhost:21081`
overrides the test's expected `http://test`. Confirmed: the same tests pass
when run from a directory without `.env`. Fix by monkeypatching
`get_settings().public_base_url = ""` in the `client` fixture, so tests derive
the base URL from request headers as intended.
2. Resolve `tests/test_hold_slayer.py::TestMockSIPEngine::test_trunk_status`.
**The test is wrong, not the code.** It asserts the mock trunk reports
`registered is True` after `start()`, but `MockSIPEngine.get_trunk_status()`
deliberately returns `registered: False` with
`reason: "No SIP trunk configured (mock mode)"` — which is exactly what the
`/health`-tells-the-truth invariant requires. Update the test to assert
`False` + the reason.
3. Triage `test_audio_classifier.py::test_complex_tone_as_music`. **Outcome: the
test was right and the code had a real bug.** Measured feature values for the
C-major chord: `music_score` = **0.850**, comfortably above the 0.7
`music_threshold` — music was detected confidently. But step 5's
`if speech_score > music_score` ran first and short-circuited, because
`speech_score` = **1.000**: the four speech bands are wide and overlapping
(flatness 0.223 ∈ (0.1,0.5), centroid 852 ∈ (500,4000), ZCR 0.039 ∈
(0.02,0.2), RMS 0.183 ∈ (0.01,0.5)) and a sustained musical chord satisfies
all four trivially. Verified structural, not a one-off — C major, A minor and
a bright chord *all* classified `live_human` despite scoring 0.85/0.65 music.
Fixed in `services/audio_classifier.py` by making the speech branch yield to a
confident music score:
`if speech_score > music_score and music_score < self.settings.music_threshold:`
One line, both thresholds stay meaningful, scorers untouched. Chords now
classify `music` (0.85) and all 18 classifier tests pass, so speech-like audio
still classifies as speech.
**This was the plan's highest-value find:** the bug is exactly the operational
failure that rings your desk for a hold queue. Worth re-validating against
*real* hold music in Phase 2 — the synthetic "bright chord" case scores 0.65,
below threshold, so it still reads as speech.
**Exit criteria:** `pytest tests/ -v` green from the repo root with the real
`.env` present ✅ (165 passed). `ruff check .` clean — **deferred, see below**.
**Deferred: `ruff check .` reports 217 errors — all pre-existing.** Verified
identical (217) with my changes stashed, so nothing here was introduced by
Phase 0, and the three files I touched add none. The backlog is stylistic and
repo-wide: 157 × `UP045` (`Optional[X]``X | None`), 18 × `F401` unused
imports, 14 × `E501`, spread across `services/` (77), `models/` (75) and
`core/` (49). Auto-fixing would rewrite annotations throughout `sippy_engine.py`
and `media_pipeline.py` — the thread-boundary and media code that currently
**cannot be exercised** (no PJSUA2, no trunk). That is a large unverifiable diff
in the highest-risk files, unrelated to making the suite an honest gate. Recommend
its own commit, ideally after Phase 1b restores the ability to actually run that
code.
---
## Phase 1a — Internal signaling with a registered softphone (no audio)
Prove the SIP stack, registration, dial plan, and call lifecycle with zero
media and zero PSTN. This is the phase that de-risks the most for the least.
**Config:**
```
USE_MOCK_SIP=false # real Sippy engine
SIP_TRUNK_HOST= # blank — engine skips _register_trunk()
GATEWAY_SIP_HOST=0.0.0.0
GATEWAY_SIP_PORT=5060
GATEWAY_SIP_DOMAIN=voip.helu.ca
```
Leaving `SIP_TRUNK_HOST` blank is deliberate and load-bearing: `SippyEngine.start()`
only calls `_register_trunk()` `if self._trunk_host`, so the gateway comes up
listening for devices with **no possible path to the PSTN**. That is the
strongest safety guarantee available in this phase — not policy, but absence of
a route.
**Setup:**
1. Register a softphone (Linphone or Zoiper on a laptop; Groundwire on mobile)
to `<caliban-ip>:5060`, any username, domain `voip.helu.ca`.
**Expect registration to succeed with any credentials** — see the security
finding below.
2. Confirm the device appears: `GET /api/v1/devices` and the `list_devices` MCP
tool, plus the `📱 SIP REGISTER` line in the log.
3. Create a `Device` row for it (`type: sip_phone`, `sip_uri` matching the
registered contact) so `call_device()` can target it.
**Validate:**
- REGISTER lands, appears in `_registered_devices`, refreshes on re-REGISTER,
and disappears on `expires=0` (hang up / unregister in the softphone).
- Inbound INVITE from the softphone to the gateway surfaces an `incoming_invite`
event and routes through the receptionist path.
- `gateway.call_device()` toward the softphone makes it **ring** — this
exercises `_call_sip_device`, SDP generation, and the loop→Sippy funnel.
- Answer, then hang up from each end in turn; confirm `terminated` propagates
and the leg is cleaned from `_legs`.
- DTMF from the softphone appears in the log (note: received DTMF currently has
no consumer — it logs and stops).
- `/health` reports `degraded` with `sip_trunk.registered: false`. **This is
correct** — do not chase a green light here.
**Security finding to address in this phase:** `_handle_incoming_register()`
replies `200 OK` to any REGISTER with no authentication challenge — no 401 with
a nonce, no digest verification. Any host that can reach port 5060 can register
as any AOR and become a transfer target. On a LAN-only bind for this phase
that's tolerable; **before Phase 3 exposes the gateway to a trunk it is not.**
Either add digest auth to the REGISTER path or firewall 5060 to known device
IPs. Flagging rather than folding in — it's a real change to the SIP path and
your call how to scope it.
**Exit criteria:** softphone registers, rings on `call_device`, both-direction
teardown is clean, no leaked legs after 10 call cycles, suite still green.
---
## Phase 1b — Gate: build PJSUA2 ✅ COMPLETE
**Done 2026-07-28.** Built out of order (ahead of Phase 1a) because nothing
useful works without it. pjproject **2.17** — newer than expected, with Python
3.13 and modern-gcc support already upstream, so no patching was needed.
`MediaPipeline.status()` now reports **`pjsua2_available: True`**; the stub-mode
warning is gone and the pipeline starts and stops cleanly at 16 kHz. Full
procedure recorded in **[pjsua2-build.md](pjsua2-build.md)**; README note now
points at it.
Notes worth carrying forward:
- **No `sudo` was needed.** Both missing tools (`swig`, `patchelf`) have PyPI
wheels and installed into the venv, so nothing on the host changed outside
`~/src` and `~/.local`.
- **The RPATH step is the non-obvious part.** The bindings compile and install
cleanly and then fail at import with
`ImportError: libpjsua2.so.2: cannot open shared object file`, because
`~/.local/lib` isn't on the loader path. Fixed with `patchelf --set-rpath` on
both the extension **and** all 12 `libpj*.so.2` libraries — patching only the
extension just surfaces the transitive deps one layer down. Chosen over
`LD_LIBRARY_PATH` because that would have to be set for uvicorn, systemd, cron
and every subprocess, and it fails at call time rather than startup. Verified
under `env -i` from `/`, so it depends on no inherited environment.
- **Configure found OpenSSL, ALSA and Opus** — the full codec/TLS surface.
- **165 tests still pass.** All `pjsua2` imports in the codebase are lazy
(inside functions), so the suite still runs without touching real media.
**Exit criteria:** `import pjsua2` succeeds in the venv ✅; `MediaPipeline`
reports `pjsua2_available: true` ✅ (note the method is `status()`, not
`get_status()` as this plan originally said).
**Two caveats this build does not solve:**
1. **Not captured by `pip install -e ".[dev]"`.** It lives outside Python
packaging metadata — a fresh venv, new host or rebuilt container needs it
repeated. Host provisioning, not a dependency.
2. **The Docker image still runs stub media** — the Dockerfile deliberately
skips this build. So the Phase 2 audio validation must run **outside the
container**, or the Dockerfile needs a pjproject build stage. Worth deciding
before Phase 2, since it determines where you test.
---
## Phase 2 — Audio, TTS, STT, and the classifier on internal calls
Now the softphone is a full test instrument: you can speak into the gateway and
hear it speak back, with no telephony charges.
**Config to fix first:**
```
TTS_BASE_URL=<real Rhema endpoint> # NOT localhost:8000 — that's this app
TTS_MODEL=speaches-ai/Kokoro-82M-v1.0-ONNX
SPEACHES_URL=http://pan.helu.ca:22070
```
**Validate leaf services standalone before wiring them into a call** — a failure
here is much easier to read outside the call path:
- `TTSService.synthesize()` returns PCM directly (a short script against the
service). Confirm `service.available` flips to `True` and `/health` reports
`tts: ok`.
- `TranscriptionService.transcribe()` against a known WAV returns expected text;
`/health` reports `stt: ok`.
- Both are reachable **from wherever the gateway actually runs** — neither
`pan.helu.ca:22070` nor `nyx.helu.ca:29000` answered from this host during
assessment. Resolve that before blaming the call path.
- LLM: `nyx.helu.ca:29000` with `Qwen3.6-35B-A3B-UD-Q4_K_XL`. Note the URL has
no `/v1` suffix while the default does — verify which the client expects.
**Then in-call, softphone ↔ gateway only:**
- Receptionist answers an inbound call from the softphone: you hear the TTS
greeting, speak, and your speech is transcribed. This single test exercises
TTS + STT + LLM + media in the real path.
- Recording writes a real file to `recordings/` with actual audio in it (check
the file plays, not just that it exists — the stub path created files too).
- Classifier: play hold music from a phone/laptop into the call and confirm
`MUSIC`; speak and confirm `LIVE_HUMAN`. This is the ground truth that
Phase 0's failing classifier test was gesturing at.
- Transcript persists to the DB on hangup and is readable via
`get_call_transcript`.
**Exit criteria:** a full receptionist conversation over the softphone with
audible TTS, accurate STT, correct classification, a playable recording, and a
persisted transcript.
---
## Phase 3 — MCP integration
Independent of audio, so it can run in parallel with 1b/2 if convenient.
- Mint a PAT via `api/tokens.py`, confirm `hs_pat_…` format.
- Connect an MCP client to `https://<host>/mcp/` and confirm OAuth discovery
(`/.well-known/oauth-protected-resource`) resolves against the real
`PUBLIC_BASE_URL`.
- Read-only tools first: `gateway_status`, `list_active_calls`, `list_devices`,
`search_call_history`, and the three resources.
- **Verify the auth boundary negatively**: a non-owner identity and a bogus PAT
both get 403/401 on `/mcp/`, REST, and WS. Owner-only is an invariant; prove
it rather than assuming it.
- `make_call` against the softphone (internal, no trunk) — confirms the MCP
path reaches the gateway.
- **Test the emergency guard through MCP specifically**: `make_call("911")`,
`"9911"`, `"112"`, `"+1911"`, and with whitespace/dashes must every one raise
`ToolError` before any SIP action. Do this while `SIP_TRUNK_HOST` is still
blank, so a guard failure cannot become an actual emergency call. **This is
the single most important test in the plan** — run it before Phase 4, never
after.
**Exit criteria:** all read tools return sane data, `make_call` rings the
softphone, every emergency variant is refused, non-owner is refused.
---
## Phase 4 — SIP trunk (first PSTN contact)
Only now does real money and a real network get involved.
1. Set `SIP_TRUNK_HOST/PORT/USERNAME/PASSWORD/TRANSPORT` for the provider; keep
`SIP_TRUNK_DID=+16472474242`.
2. Restart and watch registration: `_register_trunk()` posts `trunk_registered`,
`/health` should flip to **`healthy`** — real engine + registered trunk +
reachable DB. This is the first moment a green `/health` is meaningful.
3. **Re-run the entire emergency-guard suite from Phase 3** now that a real
route exists. The guard is what stands between an AI agent and a 911
dispatcher with no E911 location attached.
4. First outbound call: dial **your own mobile**, nothing else. Confirm ring,
answer, two-way audio, clean teardown, DB persistence.
5. First inbound: call the DID from your mobile, receptionist answers, routing
and transfer-to-softphone work.
6. Then a real hold scenario against a known IVR with a long queue.
**Safety rails while validating:**
- Drop `MAX_CONCURRENT_CALLS` to `1` for first calls; restore to 4 after.
- Keep `MAX_HOLD_TIME` low initially — 7200s is two hours of trunk time if
something wedges.
- Watch the provider's billing/CDR page live during the first calls.
- Have the provider portal open to kill calls out-of-band.
**Exit criteria:** `/health` genuinely `healthy`, one successful outbound to a
known number, one inbound handled, CDRs match expectations, no orphaned legs.
---
## Cross-cutting: `.env.example` drift ✅ FIXED (Phase 0)
`.env.example` had documented the removed `API_TOKEN` as the auth mechanism and
omitted every `CASDOOR_*` var, `OWNER_NAME`, `PUBLIC_BASE_URL`, and the whole
`TTS_*` block. Rewritten to match `config.py`: dropped `API_TOKEN`, added the
Casdoor/auth block (documenting both supported configurations and why SSO-off
off-loopback is refused), the full `TTS_*` and `RECEPTIONIST_*` blocks, and
reconciled `GATEWAY_SIP_PORT` to 5060 to match `.env`.
`TTS_BASE_URL` now defaults to `localhost:8001` in the template with a comment
warning that it must not equal the app's own `PORT` — this was blocker #3, and
the template previously would have reproduced it for any fresh checkout.
Validated by copying the template to a clean directory and parsing it through
`Settings()`: every sub-config loads, and `tts.base_url != localhost:{port}` is
asserted. `grep` confirms no `API_TOKEN` references remain in code, templates or
docs. The README config table was already current — no drift there.
---
## Summary of blockers found
| # | Blocker | Blocks | Severity |
|---|---|---|---|
| 1 | PJSUA2 not installed | all audio: TTS/STT/recording/classifier in-call | ✅ **fixed** — pjproject 2.17 built, `pjsua2_available: True` |
| 2 | REGISTER accepts any credentials, no digest auth | safe exposure of port 5060 | **security** |
| 3 | `TTS_BASE_URL` unset → defaults to app's own port | TTS entirely | config — ✅ fixed in template; **still set it in your real `.env`** |
| 4 | STT/LLM endpoints unreachable as tested | Phase 2 | environment |
| 5 | 6 failing tests (2 real, 4 `.env` bleed) | honest regression gate | ✅ **fixed** — 165 pass |
| 6 | `.env.example` documents removed `API_TOKEN` auth | fresh-environment test | ✅ **fixed** |
| 7 | Hold music classified as `LIVE_HUMAN` (found during Phase 0) | correct hold detection | ✅ **fixed** — real bug, see Phase 0 item 3 |
**Note on #3:** the fix landed in `.env.example` (the template). Your live
`.env` still has no `TTS_*` block at all, so TTS resolves to the
`localhost:8000` default and collides with the app. Set `TTS_BASE_URL` before
Phase 2.