docs: add Claude AI assistant rules and configuration
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 45s
CVE Scan & Docker Build / build-and-push (push) Successful in 1m53s

Add comprehensive rule documentation for AI-assisted development covering
authentication surfaces, outbound-call safety invariants, and other project
conventions to guide Claude's understanding of critical system behaviors.
This commit is contained in:
2026-07-28 19:01:38 -04:00
parent 016d8be71d
commit 4a3c14d4af
40 changed files with 2851 additions and 202 deletions

View File

@@ -0,0 +1,69 @@
---
description: Casdoor SSO for the browser + owner-minted PATs for MCP/CLI; owner-only on every surface; one resolver; ?token= fallback; dev-owner on loopback
paths:
- "auth.py"
- "api/auth.py"
- "api/tokens.py"
- "api/deps.py"
- "api/websocket.py"
- "mcp_server/server.py"
- "main.py"
---
# Authentication across the four surfaces
Auth is **Casdoor SSO for the browser + owner-minted Personal Access Tokens for
MCP/CLI**, and the gateway is **owner-only**: exactly one operator (the Casdoor
user whose name matches `OWNER_NAME`) may use any surface; every other identity
gets 403. There is one resolver behind all of it — don't add a second auth
mechanism, a per-surface token, or a bypass.
- **`resolve_bearer(session, raw_token)` in [auth.py](../../auth.py) is the single
resolver.** It turns a bearer string into a `User` (or `None`), classifying it
as a PAT (`hs_pat_` prefix → hash lookup) or a Casdoor JWT (RS256, validated
against the endpoint's JWKS). `resolve_from_header_or_query` wraps it to accept
the token from the `Authorization` header *or* a `?token=` query param. Every
surface funnels through these — REST, WebSocket, MCP.
- **Owner gating is `is_owner(user)` + `get_current_owner`.** `is_owner` matches
`user.name == OWNER_NAME` (SSO) or the dev-owner sub (dev mode). REST routers
carry `dependencies=[Depends(get_current_owner)]` (aliased `_auth` in
[main.py](../../main.py)) → 401 if unauthenticated, 403 if not owner. New
protected routers get the same dependency. `/auth/me` is the one exception: it
resolves the user *without* the owner gate so a signed-in non-owner sees
`is_owner:false` (the dashboard's "not authorized" screen) instead of a bare
401.
- **The `?token=` query-param fallback is intentional and narrow.** Browsers
can't set headers on a WebSocket connect or on an `<audio src>`/`<a href>`
recording download, so the current token (Casdoor JWT, or a PAT) rides as
`?token=`. It's validated by the same resolver as the header. Don't widen it or
remove it without accounting for those two consumers.
- **WebSocket checks ownership itself** ([api/websocket.py](../../api/websocket.py)
`_authorize`) rather than via a router dependency, because WS handshakes don't
run FastAPI dependencies the same way. It opens a `session_scope`, resolves the
bearer (header or `?token=`), and closes with **4401** unless the caller is the
owner. Keep the check **before** `websocket.accept()`.
- **MCP is gated by the ASGI `_owner_only_mcp` wrapper in main.py**, not by
FastMCP auth. `create_mcp_server` builds `FastMCP(auth=None)`; the wrapper reads
the ASGI scope's `Authorization` header, resolves it via the same
`resolve_from_header_or_query` + `is_owner`, and short-circuits non-owner
requests with 401/403 (plus an RFC 9728 `WWW-Authenticate` header pointing at
`/.well-known/oauth-protected-resource/mcp`). This is why PATs *and* JWTs both
work on `/mcp` with one code path. The nested `mcp_http_app.lifespan` still runs
— the wrapper is pure middleware around the inner app.
- **Dev mode is the loopback bypass, not a token.** `CASDOOR_ENABLED=false` makes
every request resolve to the dev owner — permitted **only** on a loopback bind.
`_check_startup_config` in [main.py](../../main.py) exits if SSO is disabled and
`HOST` is off-loopback (the network would see a dev-owner-open gateway), and
exits if SSO is enabled but `CASDOOR_ENDPOINT`/`CLIENT_ID`/`CLIENT_SECRET`/
`OWNER_NAME` is missing. **Never weaken these to "warn and continue" — a
startup misconfiguration must stop the service.**
- **Never log a token.** `client_secret` is a `SecretStr`; read it via
`.get_secret_value()` only where you hand it to the Casdoor SDK. PAT plaintext
is shown once at creation and only its SHA-256 hash is stored — never log the
plaintext, a JWT, or a hash.

View File

@@ -0,0 +1,45 @@
---
description: emergency-number guard, concurrent-call cap, dial-path discipline — the outbound safety invariants
paths:
- "core/dial_plan.py"
- "core/gateway.py"
- "mcp_server/server.py"
- "api/calls.py"
---
# Outbound-call safety
This is the safety core. A defect here means an unwanted real phone call, a
runaway telephony bill, or — the one that matters most — an AI-initiated
emergency call that should have been impossible.
- **`is_emergency_number()` is the single source of truth for refusal.** It
lives in `core/dial_plan.py`, blocks `911`/`9911`/`112` and their E.164
mappings (`_BLOCKED` = keys values), and normalises the input (strips
spaces, dashes, dots) before comparing. If you learn of another dialled form
that reaches emergency services, add it to `EMERGENCY_NUMBERS` — never work
around the guard.
- **Every outbound path goes through `gateway.make_call`, and the guard is its
first check** — before the concurrency cap, before `create_call`, before any
SIP action. REST `make_call`, MCP `make_call`, receptionist ring-back, and any
transfer to an external number must funnel through it. **Do not introduce a
dial path that reaches `sip_engine.make_call` without passing the guard
first.** If a new feature needs to place a call, it calls `gateway.make_call`.
- **The concurrency cap is spend control, not decoration.** `max_concurrent_calls`
(default 4) is checked in `make_call` after the emergency guard and before
call creation, using `len(call_manager.active_calls)`. Keep the ordering:
refuse-emergency, then cap, then create. Don't move the count to after
creation (it would off-by-one) and don't remove it.
- **Refusals raise `ValueError` at the gateway; surfaces translate it.**
`make_call` raises `ValueError` for both refusals; the MCP tool converts it to
`ToolError`, and the REST layer maps it to a 4xx. Keep refusals as exceptions
from the gateway — a refused call must never look like a placed one.
- **The README's `[!CAUTION]` block is a contract, not decoration.** If you
change refusal behaviour, the README caution and this rule must stay true. A
system that quietly stops refusing emergency numbers is a serious regression
even if every test still passes — add/keep a test that asserts each blocked
form is refused.

View File

@@ -0,0 +1,55 @@
---
description: the Sippy/PJSUA2 OS-thread boundary — two funnels, who owns what state, never cross it directly
paths:
- "core/sippy_engine.py"
- "core/sip_engine.py"
- "core/media_pipeline.py"
- "core/call_manager.py"
- "core/gateway.py"
---
# The thread boundary (the invariant that keeps this app sane)
The README says "single-process async." That's true at the surface, but under
the SIP engine there are **two execution contexts**: the asyncio event loop, and
a dedicated **Sippy/PJSUA2 OS thread** running the `ED2` event dispatcher. Almost
every hard-to-debug class of bug in a telephony gateway comes from touching one
context's state from the other. This app avoids that with exactly one funnel each
way. Preserve them.
- **Who owns what:**
- *Sippy thread* owns the Sippy UA objects and the `ED2` dispatcher. State:
`_ed_ua_to_leg`, `_ed_leg_to_ua` (the "ED-thread-owned state" maps). Only
touch these from a Sippy handler or a `_run_on_sippy` closure.
- *asyncio loop* owns everything else: `_legs`, `_bridges`,
`_registered_devices`, the `EventBus`, the `CallManager`, the media pipeline
wiring.
- **Cross thread → loop only via `_post_from_ed`.** It calls
`asyncio.run_coroutine_threadsafe(self._on_engine_event(kind, data), self._loop)`.
`_on_engine_event` is **the single funnel** where Sippy-thread events mutate
loop-owned state, and it runs on the loop. New Sippy-side events post through
here with a new `kind`; they do **not** reach into `_legs`/`EventBus` directly
from the handler.
- **Cross loop → thread only via `_run_on_sippy`.** It uses `ED2.callFromThread(fn)`
so `fn` runs where the Sippy objects live. In simulation mode (no `sippy`
import) it runs `fn` inline — keep that fallback so tests and stub mode work
without the native library. Anything that manipulates a UA object goes through
here.
- **Never:** read/write a Sippy UA object from the loop; never mutate `_legs`,
publish an event, or touch the `CallManager` from inside a raw Sippy callback
without going through `_post_from_ed`. If you find yourself wanting to, you're
about to introduce a data race — add a `kind` to the funnel instead.
- **Background tasks are tracked, both sides.** The gateway's `spawn()` and the
engine's `_spawn()` add tasks to a `_tasks` set with a done-callback that
discards them, so shutdown can cancel them and the GC can't drop a live
coroutine. Launch per-call/background work through these, not a bare
`asyncio.create_task` you forget to hold a reference to.
- **`MockSIPEngine` has no thread.** Tests run against it; it satisfies the same
`SIPEngine` interface synchronously/async-inline. When you extend the real
engine's behaviour, extend the mock to match, or tests will pass against a
fiction.

View File

@@ -0,0 +1,63 @@
---
description: pydantic-settings nested sub-configs + get_settings() singleton, SecretStr discipline, startup refusals, .env hygiene
paths:
- "config.py"
- "main.py"
- ".env*"
---
# Config & startup
Config is `Settings` in [config.py](../../config.py): a root `BaseSettings` with
**nested sub-config models**, each carrying its own `env_prefix`. Read it through
the `get_settings()` cached singleton.
- **`get_settings()` is the only accessor.** It memoises a single `Settings()`.
Don't construct `Settings()` elsewhere, and don't reach for `os.environ.get`
for Hold Slayer config — the whole point of the sub-config layout is that every
knob has one typed home.
- **Sub-configs own their prefixes.** `SIP_TRUNK_*``SIPTrunkSettings`, `LLM_*`
`LLMSettings`, `TTS_*``TTSSettings`, `RECEPTIONIST_*`
`ReceptionistSettings`, `CASDOOR_*``CasdoorSettings`, `CLASSIFIER_*`,
`SPEACHES_*`, `GATEWAY_SIP_*`. Root vars (`DATABASE_URL`, `HOST`, `PORT`,
`MAX_CONCURRENT_CALLS`, `USE_MOCK_SIP`, `NOTIFY_SMS_NUMBER`, `DEBUG`,
`LOG_LEVEL`, and the auth cross-cutters `OWNER_NAME` + `PUBLIC_BASE_URL`) are
unprefixed on the root model. A new knob goes in the sub-config it belongs to;
a genuinely new subsystem gets its own sub-config + prefix, not flat root vars.
- `OWNER_NAME` (the owner's Casdoor username) and `PUBLIC_BASE_URL` (OAuth
discovery base) live on the root, not under `CASDOOR_`, because they cross-cut
every surface — like `DATABASE_URL`. The Casdoor *connection* knobs
(`enabled`/`endpoint`/`client_id`/`client_secret`/`org_name`/`app_name`) live
under `CASDOOR_`.
- `HoldSlayerSettings` uses `env_prefix_allow_empty=True` with explicit
`validation_alias`es (`DEFAULT_TRANSFER_DEVICE`, `MAX_HOLD_TIME`,
`HOLD_CHECK_INTERVAL`) — i.e. those three are read *unprefixed* by design.
Follow that pattern only if you deliberately want an unprefixed name.
- **Secrets are `SecretStr`.** `casdoor.client_secret`, `sip_trunk.password`,
`llm.api_key`, `tts.api_key`. Keep new secrets as `SecretStr`; call
`.get_secret_value()` only at the point of use (outbound header, SDK
construction) — never store the bare string, never log it.
- **Startup refuses bad configs loudly, then exits.** In [main.py](../../main.py):
- `_check_startup_config` exits if `DATABASE_URL` is unset; if `CASDOOR_ENABLED`
is true but any of `CASDOOR_ENDPOINT`/`CLIENT_ID`/`CLIENT_SECRET`/`OWNER_NAME`
is missing; or if `CASDOOR_ENABLED` is false while `HOST` is off-loopback
(dev-owner mode would be open to the network). See the
[auth-surfaces rule](auth-surfaces.md).
- `_handle_db_error` turns raw asyncpg failures into human-readable guidance
(wrong password, missing DB, connection refused, bad hostname) and `sys.exit(1)`.
- SIP engine build failure and mock-vs-real are surfaced, not swallowed.
**Keep the pattern: a misconfiguration stops the service with a message a human
can act on — never a silent degrade or a stack trace with no guidance.**
- **`use_mock_sip` is opt-in for a reason.** An unconfigured trunk without
`USE_MOCK_SIP=true` must fail startup rather than boot a gateway that silently
can't place real calls. Don't default it to `True`.
- **`.env` hygiene:** `.env` is gitignored and holds real secrets — never commit
it, never treat the checked-out `.env` as a template. Only `.env.example`
(placeholders) is committed, and it must stay in sync with the models here and
the README config table. Every new var lands in all three: model,
`.env.example`, README.

View File

@@ -0,0 +1,56 @@
---
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.

View File

@@ -0,0 +1,56 @@
---
description: MCP tools return formatted strings; ToolError-vs-return-string convention; lazy gateway resolution; resources are JSON
paths:
- "mcp_server/server.py"
---
# MCP tools & resources
The MCP server ([mcp_server/server.py](../../mcp_server/server.py)) is the
AI-assistant control surface. It's built by `create_mcp_server(get_gateway)` and
mounted at `/mcp/` before the lifespan runs, so everything is resolved lazily.
- **Auth is the ASGI `_owner_only_mcp` wrapper in [main.py](../../main.py), not
FastMCP.** `create_mcp_server` builds `FastMCP(auth=None)`; the wrapper resolves
the `Authorization` bearer (Casdoor JWT or owner-minted PAT) via the shared
`resolve_from_header_or_query` + `is_owner` and returns 401/403 before the inner
app runs. Don't reintroduce a FastMCP verifier here — one resolver gates all
four surfaces (see the [auth-surfaces rule](auth-surfaces.md)). MCP clients use
a PAT (`hs_pat_…`) minted from the dashboard's Tokens modal.
- **Tools return plain formatted strings; resources return JSON strings.** Tools
produce human-readable text an assistant reads back to a user (`"Call abc123
initiated. …"`). Resources (`gateway://status`, `gateway://call-flows`,
`gateway://active-calls`) return `json.dumps(...)`. Don't blur these — a tool
that returns raw JSON, or a resource that returns prose, breaks the contract.
- **Error convention — match the two existing patterns:**
- **Raise `ToolError`** when the request is invalid or unsafe: emergency
number, bad mode, concurrency cap hit, or "gateway still starting"
(`require_gateway`). The assistant should treat these as errors.
- **Return an error string** for a lookup that simply found nothing or hit a
recoverable snag: `"Call {id} not found."`, `"No stored call flow for …"`,
`"Error looking up …: {e}"`. The assistant reads these as content.
- Rule of thumb: *"you asked for something invalid/unsafe" → raise; "I looked,
here's the (maybe empty/failed) answer" → return.*
- **`require_gateway()` gates every tool that needs the live gateway.** It raises
`ToolError("Gateway is still starting up …")` when `get_gateway()` returns
`None`. This is why the MCP app can mount before the lifespan builds the
gateway. Call it at the top of any tool that touches the gateway; never assume
the gateway exists.
- **`make_call` is the one tool that dials.** It maps the string `mode` to
`CallMode`, defaults unknown modes to `DIRECT`, and lets `gateway.make_call`'s
refusals (`ValueError`) surface as `ToolError`. The emergency guard and
concurrency cap live in the gateway, **not** here — don't reimplement or skip
them at the tool layer (see the call-safety rule).
- **DB-backed tools use `session_scope()`** and read from `call_persistence`.
Completed-call history, summaries, recordings, and stored flows come from the
database, not from `active_calls` (those are live only). Keep the
`async with session_scope() as session:` pattern; don't open ad-hoc sessions.
- **Keep the tool count and README table in sync.** There are 15 tools + 3
resources. If you add/remove one, update the README's MCP table and the
`docs/mcp-server.md` reference — a drifting tool list is a documented lie.