Stage 3: composition root, core↔services cycle break, shared REST+MCP data layer #3

Merged
r merged 1 commits from feature/stage3-structure into feature/stage2-concurrency 2026-07-10 17:48:31 +00:00
Owner

Third stage of the architecture-review roadmap. Stacked on #2 — merge #1#2 → this.

What changes

Composition root moves to main.py's lifespan. The gateway was composing its own services (with function-local from services... imports working around a core↔services import cycle), and the lifespan poked gateway._recording_service from outside. Now every service is built and wired in one place; core/ no longer imports services/ anywhere — the cycle is dead because main sits above both. AIPSTNGateway.from_config() is deleted; tests compose explicitly.

Inbound-call policy moves to the receptionist. The full _on_sip_incoming_call body (routing evaluation, reject/answer, screening dispatch) is now ReceptionistService.on_inbound_call, wired as the engine's on_incoming_call callback by the lifespan. Receptionist dependencies (tts / transcription / recording / routing) are constructor-injected — no more gateway._tts reach-through, and the import of hold_slayer's private _get_llm is replaced by a shared services.llm_client.get_llm.

The gateway shrinks to what it should be: live-call ops, device registry, tracked tasks. Hold-slayer launch goes through a mode-handler registry (register_mode_handler(CallMode.HOLD_SLAYER, ...)) so the gateway no longer knows the service's type. CallManager takes on_call_ended in its constructor. build_sip_engine() is a pure function taking explicit callbacks. api/routing.py gets the routing service from app.state via a proper dependency instead of gateway._routing.

One data layer for both surfaces. db.session_scope() is the single session convention (get_db wraps it for REST; MCP/services use it directly — kills the get_session_factory vs get_db split). services/call_persistence.py now holds the shared query/write functions plus the single StoredCallFlow→CallFlow mapper; api/call_flows.py, api/call_history.py, and the six DB-touching MCP tools are thin wrappers over them, so REST and MCP can't drift. The three private _call_legs scans (gateway transfer/hangup, REST dtmf, MCP dtmf) now use CallManager.legs_for_call().

Tests & verification

  • 7 new tests in tests/test_structure.py: mode-handler launch, constructor on_call_ended hook, receptionist inbound answer/reject paths, and call-flow CRUD + history routes exercised over httpx-ASGI against a real SQLite database through the shared layer (aiosqlite added to dev extras).
  • Suite: 121 passed, same 2 pre-existing failures as main.
  • Live boot against the real Postgres: routing-rules and call-flows endpoints answering, zero errors, clean shutdown.

Notes

  • Housekeeping: the services/call_analytics.py deletion described in #1 actually lands in this commit — a stash round-trip during lint-baseline comparison had unstaged the git rm, so it rode the working tree until git add -u here. End state is as described in #1.

🤖 Generated with Claude Code

Third stage of the architecture-review roadmap. **Stacked on #2** — merge #1 → #2 → this. ## What changes **Composition root moves to `main.py`'s lifespan.** The gateway was composing its own services (with function-local `from services...` imports working around a core↔services import cycle), and the lifespan poked `gateway._recording_service` from outside. Now every service is built and wired in one place; `core/` no longer imports `services/` anywhere — the cycle is dead because main sits above both. `AIPSTNGateway.from_config()` is deleted; tests compose explicitly. **Inbound-call policy moves to the receptionist.** The full `_on_sip_incoming_call` body (routing evaluation, reject/answer, screening dispatch) is now `ReceptionistService.on_inbound_call`, wired as the engine's `on_incoming_call` callback by the lifespan. Receptionist dependencies (tts / transcription / recording / routing) are constructor-injected — no more `gateway._tts` reach-through, and the import of hold_slayer's private `_get_llm` is replaced by a shared `services.llm_client.get_llm`. **The gateway shrinks to what it should be**: live-call ops, device registry, tracked tasks. Hold-slayer launch goes through a mode-handler registry (`register_mode_handler(CallMode.HOLD_SLAYER, ...)`) so the gateway no longer knows the service's type. `CallManager` takes `on_call_ended` in its constructor. `build_sip_engine()` is a pure function taking explicit callbacks. `api/routing.py` gets the routing service from `app.state` via a proper dependency instead of `gateway._routing`. **One data layer for both surfaces.** `db.session_scope()` is the single session convention (`get_db` wraps it for REST; MCP/services use it directly — kills the `get_session_factory` vs `get_db` split). `services/call_persistence.py` now holds the shared query/write functions plus the single `StoredCallFlow→CallFlow` mapper; `api/call_flows.py`, `api/call_history.py`, and the six DB-touching MCP tools are thin wrappers over them, so REST and MCP can't drift. The three private `_call_legs` scans (gateway transfer/hangup, REST dtmf, MCP dtmf) now use `CallManager.legs_for_call()`. ## Tests & verification - 7 new tests in `tests/test_structure.py`: mode-handler launch, constructor `on_call_ended` hook, receptionist inbound answer/reject paths, and call-flow CRUD + history routes exercised over httpx-ASGI against a real SQLite database through the shared layer (`aiosqlite` added to dev extras). - Suite: **121 passed**, same 2 pre-existing failures as `main`. - Live boot against the real Postgres: routing-rules and call-flows endpoints answering, zero errors, clean shutdown. ## Notes - Housekeeping: the `services/call_analytics.py` deletion described in #1 actually lands in this commit — a stash round-trip during lint-baseline comparison had unstaged the `git rm`, so it rode the working tree until `git add -u` here. End state is as described in #1. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
r added 1 commit 2026-07-10 00:29:56 +00:00
The gateway was the composition root, device registry, inbound-call
policy, and call-operations service in one class, with core↔services
circular imports papered over by function-local imports, wiring done
by assigning private attributes, and MCP tools duplicating REST query
logic against their own sessions.

Composition:
- main.py's lifespan now builds every service and wires them by
  constructor/registration. gateway.from_config() is gone; core/ no
  longer imports services/ anywhere — the cycle is dead.
- Inbound-call policy moved to ReceptionistService.on_inbound_call
  (routing evaluation, reject/answer, screening dispatch); wired as
  the engine's on_incoming_call by the lifespan. Receptionist deps
  (tts/transcription/recording/routing) are constructor-injected —
  no more gateway._tts reach-through or importing hold_slayer's
  private _get_llm (now services.llm_client.get_llm, shared).
- Hold-slayer launch goes through a mode-handler registry
  (register_mode_handler); the gateway no longer knows the service's
  type. CallManager takes on_call_ended in its constructor.
- build_sip_engine() is a pure function taking explicit callbacks.
- api/routing.py uses the routing service from app.state via a
  proper dependency instead of gateway._routing.

Shared data layer:
- db.session_scope() is the one session convention (get_db wraps it).
- services/call_persistence.py gains the query/write functions and
  the single StoredCallFlow→CallFlow mapper; api/call_flows.py,
  api/call_history.py, and the six DB-touching MCP tools are thin
  wrappers over them — the two surfaces can't drift.
- legs_for_call() replaces the three private _call_legs scans
  (gateway transfer/hangup, REST dtmf, MCP dtmf).

7 new tests (mode-handler launch, on_call_ended hook, receptionist
inbound answer/reject, call-flow CRUD round-trip and history routes
against real SQLite through the shared layer). aiosqlite added to dev
deps for that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
r merged commit db3184503d into feature/stage2-concurrency 2026-07-10 17:48:31 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: r/hold-slayer#3