--- 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.