Tenant Overlay System
The voice channel runs in a multi-tenant SaaS. Each hospital tenant has a different name, different campus addresses, different doctors, and (sometimes) different STT mishears specific to its directory. The challenge: serve all of these correctly without hardcoding any of them in shared code or in YAML.
This page documents the tenant overlay architecture introduced May 4, 2026 (commit 20ec058).
Two material updates since the original architecture:
- FAQ purge (ADR-0055). All 10 ZOL-specific hand-curated FAQ entries were removed on 2026-05-12 (commit
a9820c3f). The surviving FAQ surface inzol.yamlnow contains only safety/policy entries (crisis, emergency dispatch) plus theovernacht_ambiguityclarification. Patient-facing factual answers come from the RAG corpus, not from the YAML. - Admin UI (Sprint E Wave D). Hospital admins can now CRUD their overlay from
/admin/voice-overlay/<slug>— routing-rule edit modal, taxonomy inline-edit + add-new row, YAML import/export tab, empty-tenant onboarding banner. No engineer-only YAML required for routine changes. Backend surface:GET / POST / PUT / DELETE /api/v1/admin/voice-overlay/{slug}plusPOST /api/v1/admin/voice-overlay/{slug}/importandGET .../export. Implementation commits:e3297cd0(read API, Wave B) →0e82ba5d(viewer UI, Wave C) →1130f103(write API + import/export, Wave D backend) →9b6ae389/324f7bfa/5214b88f(D3/D4/D5 frontend) →2ba8956a(empty-tenant banner).
The problem
Before the overlay system, two locations contained tenant-specific data:
_FAQ_ENTRIESinvoice_faq_tool.pyhad a hardcodedaddress_generalanswer with a specific street address ("Schiepse Bos 6 in Genk"). When the hospital relocated, the FAQ silently served stale content forever._STT_NORMALIZATIONSinintent_classification_service.pymixed generic Dutch phonetic mishears (decoren → doctoren) with tenant-specific corrections (zon → zolfor the ZOL hospital name).
Both patterns failed multi-tenant correctness: shipping hospital #2 would mean either editing shared code (bad) or shipping their data with hospital #1's still in place (worse).
The architecture
Three categories of voice config, each with a different home:
| Category | Where it lives | Why |
|---|---|---|
| Generic patterns + language rules | Source code (shared _FAQ_ENTRIES, _STT_NORMALIZATIONS) | Hospital-agnostic; benefits any tenant |
| Tenant-specific phonetic-recovery (STT mishears) | YAML overlay (tenant_overlays/_yaml/<slug>.yaml) | Phonetic data that doesn't exist anywhere else |
| Tenant data (addresses, names, hours) | DB + registered Python renderer | Single source of truth; no duplication |
At request time the three are composed:
shared registry (generic, language-level) ← source code
+ tenant overlay (STT mishears) ← _yaml/<slug>.yaml
+ DB-driven renderers (campus listing, ...) ← reads via get_taxonomy(slug)
─────────────────────────────────────────────────
effective voice config for this turn
Components
tenant_overlays/ package
schema.py— Pydantic models for the YAML structure. Top-level fields:tenant,faq_entries,stt_normalizations. Each FAQ entry has per-language pattern + answer maps.extra="forbid"means typos (e.g.,faq_entreis) fail loud at boot.loader.py— Reads one YAML file, validates against the schema, compiles regex patterns tore.Patterninstances, and returns an immutableTenantOverlaydataclass. Any error (bad YAML, invalid regex, unknown language code, mismatched filename↔tenant) raisesInvalidTenantOverlayat boot. Bad config never reaches a request.registry.py— Eager-loads every*.yamlunder_yaml/at import time.get_overlay(slug)returns the matching overlay orEMPTY_OVERLAYfor unknown tenants (graceful fallback, not an error).reload_overlays()re-scans the directory for dev hot-reload._yaml/<slug>.yaml— One file per tenant. Carries STT mishears today; can hold tenant-specific FAQ overrides in the future.
voice_faq_renderers.py — DB-driven answer composition
The shared _FAQ_ENTRIES declares an intent and its match patterns in code. For entries whose answer depends on tenant DB data, the entry sets answer_renderer="<name>" instead of a static answers map:
FAQEntry(
key="address_all_campuses",
patterns={
"nl": [re.compile(r"\b(alle\s+campussen|...)\b", re.IGNORECASE)],
"en": [re.compile(r"\ball\s+campuses\b", re.IGNORECASE)],
# ...
},
answer_renderer="list_campuses", # NEW field
)
At match time, match_faq looks up the renderer in RENDERERS and invokes it with (language, tenant_slug). The render_campus_listing renderer reads get_taxonomy(slug).hospital_campuses (DB-cached, in-memory after startup) and formats per-language prose. House numbers and postal codes stay as digits per the TTS rule.
Add a campus to the DB → next request reflects it. Zero sync gap.
YAML schema (one file per tenant)
# tenant_overlays/_yaml/zol.yaml
tenant: zol
stt_normalizations:
# Hospital name mishears (STT homophone)
zon: zol
sol: zol
# Clinician surnames
geerte: geert
jurissen: jeurissen
jurissens: jeurissen
# faq_entries: [] # tenant-specific FAQ overrides go here when needed
Wire-up across the voice path
The tenant_slug is resolved once per WS connection (from Settings.public_tenant_slug) and threaded through:
Inside rag_service, the slug is already resolved upstream via DB lookup and stored in s["hospital_config_key"] — _classify_intent_and_rewrite reads it from there and passes to _normalize_stt_mistakes, no extra plumbing through query_stream needed.
Onboarding a new hospital tenant
- Create
app/services/voice/tenant_overlays/_yaml/<new-slug>.yamlwith their tenant slug astenant:and any STT phonetic mishears specific to their hospital name + clinician surnames. - Set
PUBLIC_TENANT_SLUG=<new-slug>in the deployment env. - Populate the
app.hospitalsrow,app.campusesrows,app.departmentsrows in the database (the hospital-agnostic phase 5 work from April 10 already enables this — no code changes). - Restart the backend.
The same shared _FAQ_ENTRIES (campus listing, visiting hours, parking, phone numbers, etc.) and the same renderer code serves the new tenant automatically.
Failure modes — the design wants these to be loud
| Failure | Where it surfaces |
|---|---|
| Malformed regex in a YAML pattern | InvalidTenantOverlay at process boot — backend crashes refusing to start |
| Unknown language code (typo) | Same — boot-time crash with file path + entry key |
YAML filename doesn't match tenant: field | Same — silent renames are forbidden |
| Renderer returns None (no DB data, unknown lang) | FAQ matcher falls through to next entry — eventually to RAG |
| Tenant slug not in registry | EMPTY_OVERLAY returned; shared registry still applies (graceful) |
The asymmetry is deliberate: boot-time errors are non-recoverable (operator must fix the YAML), runtime fall-throughs are recoverable (RAG handles unknown queries fine).
What's NOT in the YAML
By policy:
- Campus addresses, phone numbers, opening hours — DB, accessed via renderers.
- Doctor names, department canonical names — DB (hospital taxonomy).
- Hospital website URL, branded full name —
PromptContext, populated fromapp.hospitalsrow.
Anything that already lives in the database stays there.
Reference
- Implementation commit:
20ec058— feat(voice): tenant-aware overlay system + DB-driven FAQ renderers - Schema validator:
app/services/voice/tenant_overlays/schema.py - Loader:
app/services/voice/tenant_overlays/loader.py - Registry + public API:
app/services/voice/tenant_overlays/__init__.py - Renderers:
app/services/voice/voice_faq_renderers.py - Tests:
tests/unit/services/voice/test_tenant_overlay_*.py,test_voice_faq_renderers.py