docs: add Claude AI assistant rules and configuration
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:
69
.claude/rules/auth-surfaces.md
Normal file
69
.claude/rules/auth-surfaces.md
Normal 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.
|
||||
45
.claude/rules/call-safety.md
Normal file
45
.claude/rules/call-safety.md
Normal 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.
|
||||
55
.claude/rules/concurrency-threads.md
Normal file
55
.claude/rules/concurrency-threads.md
Normal 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.
|
||||
63
.claude/rules/config-startup.md
Normal file
63
.claude/rules/config-startup.md
Normal 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.
|
||||
56
.claude/rules/lifespan-wiring.md
Normal file
56
.claude/rules/lifespan-wiring.md
Normal 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.
|
||||
56
.claude/rules/mcp-tools.md
Normal file
56
.claude/rules/mcp-tools.md
Normal 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.
|
||||
Reference in New Issue
Block a user