--- description: composition-root lifespan, nested MCP http_app lifespan, app.state wiring, route/mount ordering, honest /health paths: - "main.py" - "api/deps.py" - "core/gateway.py" --- # Lifespan, composition root & route ordering [main.py](../../main.py)'s `lifespan` is the **composition root**: it builds the gateway and every service, wires them by constructor/registration, and hangs the long-lived ones on `app.state`. This is the one place dependencies are assembled. - **Build services here, inject them — nothing self-constructs its deps.** The gateway, classifier, transcription, TTS, routing, recording, receptionist, and notification services are all constructed in the lifespan and wired together (e.g. the receptionist receives tts/transcription/recording/routing; `launch_hold_slayer` is registered as the `HOLD_SLAYER` mode handler). A new service is built here and passed in, not instantiated deep in a call path. - **The MCP sub-app's lifespan MUST be nested.** The lifespan opens `async with mcp_http_app.lifespan(app):` around all startup. FastMCP's streamable-HTTP session manager is initialised inside *its* lifespan; mount the app without entering that context and every `/mcp` request 500s ("session manager not initialised" / "Task group is not initialized"). **Keep the nesting.** This is the same landmine across the estate's mounted-MCP services. - **`app.state` is the handoff to request handlers.** The lifespan sets `app.state.gateway`, `.routing_service`, `.transcription_service`, `.notification_service`, `.recording_service`. Dependencies in [api/deps.py](../../api/deps.py) read these and raise `503` if not yet set. MCP tools reach the gateway via the lazy `_get_gateway_instance` resolver. Don't reach for module-level globals; go through `app.state`. - **Route/mount registration order is load-bearing:** 1. `call_history` router registers **before** `calls` — both live under `/api/v1/calls`, and `calls`' `GET /{call_id}` would otherwise swallow the literal path `history`. Keep history first. 2. The `"/mcp"` mount and all API/WS/health routes register **before** the `"/"` static dashboard mount — a root mount matches every path, so anything after it is unreachable. The dashboard mount stays last, and only when `dashboard/build/` exists. - **`/health` is honest by construction.** `healthy` = real (non-`MockSIPEngine`) engine **and** registered trunk **and** reachable DB; it also reports STT/TTS last-known reachability via `_availability`. Don't relax any of these to make a probe pass — a degraded gateway must read as `degraded`, with the reason visible. - **Shutdown reverses startup.** Stop notifications, stop the gateway (which cancels tracked tasks, ends active calls, stops SIP then media), close the DB. New long-lived resources get a matching teardown here — don't leak a task or a client across restarts.