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:
240
CLAUDE.md
Normal file
240
CLAUDE.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# CLAUDE.md — Hold Slayer 🔥🐾
|
||||
|
||||
Red Panda Standards for the Hold Slayer telephony gateway. This is an
|
||||
**AI-powered PSTN gateway**: it places real phone calls, navigates IVR menus,
|
||||
waits on hold, and rings a human's desk phone when a live person answers. It
|
||||
also answers inbound calls with an AI receptionist and smart routing.
|
||||
|
||||
**It dials real numbers on a real SIP trunk and may incur telephony charges,
|
||||
and an AI agent drives it.** Treat every change through that lens: a bug here
|
||||
isn't a 500, it's an unwanted phone call — or a *refused emergency call that
|
||||
should never have been attempted in the first place*. Read this file before
|
||||
touching call placement, auth, the SIP thread boundary, or the emergency guard.
|
||||
|
||||
Lead with a paw print in this repo.
|
||||
|
||||
---
|
||||
|
||||
## The shape of this thing (read once, then it's obvious)
|
||||
|
||||
One FastAPI process exposes four surfaces over the same port: **REST** (`/api/v1/*`),
|
||||
**WebSocket** (`/ws/*`), an **MCP server** (streamable HTTP at `/mcp/`), and the
|
||||
built **SvelteKit dashboard** at `/`. All four are **owner-only**, gated by Casdoor
|
||||
SSO (browser JWT) or an owner-minted PAT through one shared resolver.
|
||||
|
||||
Under them sits a **composition root** in [main.py](main.py)'s `lifespan`: the
|
||||
gateway and every service are constructed and wired there, then hung on
|
||||
`app.state`. Nothing constructs its own dependencies — if you need a new
|
||||
service, build it in the lifespan and pass it in.
|
||||
|
||||
Below the services is the part that makes this app unusual: a **`SippyB2BUAEngine`
|
||||
that runs the SIP/PJSUA2 event loop on its own OS thread**, not the asyncio loop.
|
||||
This is the highest-leverage invariant in the codebase. See
|
||||
[the concurrency rule](.claude/rules/concurrency-threads.md) — the README's
|
||||
"single-process async" line is a simplification; there are two execution
|
||||
contexts and exactly one funnel between them.
|
||||
|
||||
```
|
||||
REST / WS / MCP / Dashboard (asyncio, FastAPI)
|
||||
│
|
||||
composition root (lifespan) → services → gateway
|
||||
│
|
||||
SippyB2BUAEngine ──┬── asyncio side (legs, bridges, event bus)
|
||||
└── Sippy thread (UA objects, ED2 dispatcher)
|
||||
↑ crossed only via _post_from_ed / _run_on_sippy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Red Panda Approval™ — what it means here
|
||||
|
||||
1. **Fresh Environment Test** — `cp .env.example .env`, set `DATABASE_URL` and
|
||||
either `CASDOOR_*` + `OWNER_NAME` (SSO) or `CASDOOR_ENABLED=false` with
|
||||
`HOST=127.0.0.1` (dev-owner, loopback only), `pip install -e ".[dev]"`,
|
||||
`uvicorn main:app`. It must boot to a clear log banner or **exit with a
|
||||
human-readable reason** (see `_check_startup_config` / `_handle_db_error` in
|
||||
[main.py](main.py)). It must *never* boot into a state where it silently can't
|
||||
place calls — that's why `use_mock_sip` is opt-in and an unconfigured trunk
|
||||
fails startup.
|
||||
2. **Elegant Simplicity** — the composition root wires; services do one job;
|
||||
the thread boundary has exactly one funnel each way. Don't add a second path
|
||||
across the thread line, a second auth mechanism, or a service that reaches
|
||||
into another service's internals.
|
||||
3. **Observable & Debuggable** — `/health` is *honest*: it reports `degraded`
|
||||
with the reason (mock engine, unregistered trunk, DB down, STT/TTS
|
||||
unreachable) rather than a green light that lies. Keep it honest. Events flow
|
||||
through the typed `EventBus`; new call-lifecycle facts become typed events,
|
||||
not `print`s.
|
||||
4. **Consistent Patterns** — config via pydantic-settings sub-configs; MCP tools
|
||||
return formatted strings; REST returns Pydantic models; DB access via
|
||||
`session_scope()`. Match the neighbours.
|
||||
5. **Actually Works** — `pytest tests/ -v` (146 tests across 16 files). A change
|
||||
to call placement, routing, the classifier, or auth needs a test. The suite
|
||||
runs against SQLite (`aiosqlite`) and the mock SIP engine — no trunk, no
|
||||
Postgres required to test.
|
||||
|
||||
---
|
||||
|
||||
## Invariants that must not be weakened
|
||||
|
||||
These are safety- and correctness-critical. Loosening one is never a casual
|
||||
refactor — it needs an explicit rationale and, where it deviates from a stated
|
||||
standard, a note (there is no `docs/EXCEPTIONS.md` yet; if you start
|
||||
accumulating documented deviations, create one rather than letting them go
|
||||
unrecorded).
|
||||
|
||||
- **The emergency-number guard is absolute.** `is_emergency_number()` in
|
||||
[core/dial_plan.py](core/dial_plan.py) blocks `911`/`9911`/`112` (and their
|
||||
E.164 forms, whitespace/dashes stripped) at `gateway.make_call`, **before**
|
||||
the concurrency check and before any SIP action. Every dialling path — REST
|
||||
`make_call`, MCP `make_call`, receptionist call-back, transfer to an external
|
||||
number — must pass through a guard that refuses these. An AI agent must never
|
||||
place an emergency call, and API calls carry no E911 location. **Do not add a
|
||||
dial path that bypasses `make_call`'s guard.** If you add a new outbound path,
|
||||
it calls the guard first. See [the safety rule](.claude/rules/call-safety.md).
|
||||
|
||||
- **The concurrency cap is real spend control.** `max_concurrent_calls` (default
|
||||
4) caps simultaneous outbound calls in `gateway.make_call`. It's checked after
|
||||
the emergency guard, before creating the call. Don't remove it or move it
|
||||
below call creation.
|
||||
|
||||
- **The Sippy thread boundary is crossed only through the two funnels.** Sippy
|
||||
UA objects and the `ED2` dispatcher live on the Sippy thread; legs, bridges,
|
||||
and the event bus live on the asyncio loop. Cross **thread→loop** only via
|
||||
`_post_from_ed` (→ `run_coroutine_threadsafe` → the single `_on_engine_event`
|
||||
funnel) and **loop→thread** only via `_run_on_sippy` (→ `ED2.callFromThread`).
|
||||
Never touch a Sippy UA object from the loop; never mutate loop-owned state from
|
||||
a Sippy handler. This is the whole reason the app is thread-safe.
|
||||
|
||||
- **Auth is Casdoor SSO + owner-minted PATs, owner-only, one resolver.** The
|
||||
browser signs in via Casdoor (short-lived JWT); MCP/CLI clients use owner-minted
|
||||
PATs (`hs_pat_…`). `resolve_bearer` in [auth.py](auth.py) turns either into a
|
||||
`User`, and `is_owner`/`get_current_owner` gate every surface to the single
|
||||
operator (`OWNER_NAME`) — non-owners get 403. Dev mode (`CASDOOR_ENABLED=false`)
|
||||
resolves the dev owner and is **only** permitted on a loopback bind;
|
||||
`_check_startup_config` refuses SSO-off-loopback and SSO-on-with-missing-config.
|
||||
Keep those refusals — a silent dev-owner-open-on-0.0.0.0 is the failure mode
|
||||
they exist to prevent. See [the auth rule](.claude/rules/auth-surfaces.md).
|
||||
|
||||
- **`/health` tells the truth.** `healthy` requires a real (non-mock) engine, a
|
||||
registered trunk, and a reachable DB. Don't relax it to make a probe go green;
|
||||
a gateway that can't place calls is not healthy, and the dashboard/operator
|
||||
needs to see that.
|
||||
|
||||
- **The database is the source of truth for history; live state is in memory.**
|
||||
Active calls live in the `CallManager`; completed calls + transcripts are
|
||||
persisted on hangup via `call_persistence`. MCP/REST history and summaries read
|
||||
from the DB through `session_scope()`. Don't confuse the two — a call that
|
||||
ended is gone from `active_calls` and only exists in the DB.
|
||||
|
||||
---
|
||||
|
||||
## Config — pydantic-settings, nested, singleton
|
||||
|
||||
Config is `Settings` in [config.py](config.py): a root `BaseSettings` with
|
||||
**nested sub-config models** (`SIPTrunkSettings`, `LLMSettings`, `TTSSettings`,
|
||||
`ReceptionistSettings`, …), each with its own `env_prefix`
|
||||
(`SIP_TRUNK_`, `LLM_`, `TTS_`, `RECEPTIONIST_`, …). Read config through
|
||||
`get_settings()` (a cached singleton) — don't call `os.environ.get` for
|
||||
Hold Slayer settings, and don't construct `Settings()` yourself outside that
|
||||
accessor.
|
||||
|
||||
> **Estate note (not a defect):** unlike the `KERNOS_`/`ARGOS_`/`NIKE_`-style
|
||||
> single-prefix services, Hold Slayer has **no one umbrella prefix** — root vars
|
||||
> (`DATABASE_URL`, `HOST`, `MAX_CONCURRENT_CALLS`, `OWNER_NAME`) are unprefixed
|
||||
> and each subsystem carries its own (`CASDOOR_` for the SSO connection). That's a
|
||||
> deliberate readability choice for
|
||||
> a config with this many subsystems; keep new vars consistent with the
|
||||
> sub-config they belong to, and if you add a new subsystem, give it its own
|
||||
> sub-config + prefix rather than piling flat vars onto the root.
|
||||
|
||||
Secrets (`casdoor.client_secret`, `sip_trunk.password`, `llm.api_key`,
|
||||
`tts.api_key`) are
|
||||
`SecretStr` — keep them so, and read via `.get_secret_value()` only at the point
|
||||
of use. Every new var also needs a row in [.env.example](.env.example) and the
|
||||
README's config table. See [the config rule](.claude/rules/config-startup.md).
|
||||
|
||||
**`.env` hygiene:** `.env` is gitignored and holds real secrets — never commit
|
||||
it, never treat the local `.env` as a template. Only `.env.example`
|
||||
(placeholders) is committed.
|
||||
|
||||
---
|
||||
|
||||
## MCP tools — formatted strings, and the error convention to know
|
||||
|
||||
MCP tools ([mcp_server/server.py](mcp_server/server.py)) return **plain
|
||||
human-readable strings** (not JSON, not Pydantic models — that's the REST
|
||||
layer's job). Resources (`gateway://status`, `gateway://call-flows`,
|
||||
`gateway://active-calls`) return JSON strings.
|
||||
|
||||
There's a **deliberate but uneven error convention** worth understanding before
|
||||
you add a tool:
|
||||
|
||||
- `make_call` raises `ToolError` on a bad request (emergency number, bad mode,
|
||||
cap hit) — a hard failure the assistant should treat as an error.
|
||||
- Most read/lookup tools **return** an error *string* (`"Call … not found."`,
|
||||
`"Error looking up …: {e}"`) instead of raising — a soft "here's what
|
||||
happened" the assistant reads as content.
|
||||
|
||||
When you add a tool: raise `ToolError` for "you asked for something invalid or
|
||||
unsafe"; return a plain string for "I looked and here's the (possibly empty)
|
||||
answer." The gateway is resolved lazily per call via `require_gateway()` (it
|
||||
raises `ToolError` while the gateway is still starting) — keep that, because the
|
||||
MCP app is mounted before the lifespan builds the gateway. See
|
||||
[the MCP rule](.claude/rules/mcp-tools.md).
|
||||
|
||||
---
|
||||
|
||||
## What's real vs. stubbed (don't mistake one for the other)
|
||||
|
||||
- **PJSUA2 media pipeline runs in stub mode** unless the `pjsua2` bindings are
|
||||
built from pjproject (not pip-installable). Signaling works; audio
|
||||
routing/recording is a no-op stub without them. Code that assumes real audio
|
||||
must degrade honestly, and `/health`/engine-mode must reflect stub vs real.
|
||||
- **The mock SIP engine (`MockSIPEngine`) must be asked for** (`USE_MOCK_SIP=true`).
|
||||
It exists for tests and local dev. Production must not silently run on it —
|
||||
`/health` reports `engine: mock` and refuses `healthy`.
|
||||
|
||||
---
|
||||
|
||||
## Known gaps (flag, don't fold — these are not your task unless asked)
|
||||
|
||||
Surfaced honestly so you don't rediscover them as surprises. The README's
|
||||
Phase 4/5/6 checklists track most of these; **don't fold fixes into unrelated
|
||||
work** — raise them.
|
||||
|
||||
- **No structured JSON logging.** Logging is plain `logging.basicConfig` in
|
||||
[main.py](main.py); there's no `LOG_FORMAT`/JSON path (README Phase 4 has this
|
||||
unchecked). If Heluca observability wants JSON logs shipped to a collector,
|
||||
that's a deliberate piece of work, not a drive-by.
|
||||
- **No `/metrics` endpoint and no Prometheus.** Unlike the metrics-bearing
|
||||
estate services, there's no exposition endpoint here yet.
|
||||
- **No health-probe access-log filter.** Every `/health` poll hits the access
|
||||
log. Other estate services suppress probe noise; this one doesn't.
|
||||
- **No rate limiting** on API endpoints (README Phase 4, unchecked).
|
||||
- **Docker: single-image `Dockerfile` + `docker-compose.yaml`** (app +
|
||||
`postgres:17`) ship in-repo; the Gitea CI (`cve-scan-docker-build.yml`) builds
|
||||
the image on push to `main`. The compose stack requires SSO enabled
|
||||
(published port ⇒ 0.0.0.0 ⇒ dev-owner mode refused). No systemd unit in-repo.
|
||||
- **A committed `.DS_Store` is *not* present** (good), but do check `git status`
|
||||
stays clean of OS cruft; `.gitignore` already lists it.
|
||||
|
||||
---
|
||||
|
||||
## Working here
|
||||
|
||||
- **Run:** `uvicorn main:app --host 0.0.0.0 --port 8000` (or `python main.py`).
|
||||
The CLI `--port` wins over `settings.port` in the startup banner logic — a real
|
||||
gotcha the banner code already accounts for; don't "fix" it into disagreement.
|
||||
- **Test:** `pytest tests/ -v`. Fast, no external services (SQLite + mock SIP).
|
||||
- **Lint:** `ruff check .` (line length 100, `py312` target).
|
||||
- **Dashboard:** `cd dashboard && npm install && npm run build` → served at `/`
|
||||
when `dashboard/build/` exists. The API/WS/health/MCP routes are registered
|
||||
**before** the `"/"` static mount because a root mount matches everything —
|
||||
keep that ordering.
|
||||
- **DB migrations:** Alembic (`db/migrations/`), upgrade-on-boot via `init_db`.
|
||||
Schema changes are migrations, never ad-hoc `CREATE`.
|
||||
|
||||
Path-scoped rules in [.claude/rules/](.claude/rules/) load automatically when you
|
||||
edit the files they cover. They carry the fine-grained "don't break this"
|
||||
detail; this file is the map.
|
||||
Reference in New Issue
Block a user