Files
hold-slayer/.claude/rules/config-startup.md
Robert Helewka 4a3c14d4af
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
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.
2026-07-28 19:01:38 -04:00

3.5 KiB

description, paths
description paths
pydantic-settings nested sub-configs + get_settings() singleton, SecretStr discipline, startup refusals, .env hygiene
config.py
main.py
.env*

Config & startup

Config is Settings in 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_aliases (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:

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