← Back to index | ← 01 Overview | 03 Format taxonomy →
How tenancy works at every layer: database, gateway, public app, dashboard, WordPress bridge. Every per-tenant feature uses one of the patterns described here.
The tenant identifier
A tenant is identified by a short lowercase string in the tenants.id column. Today:
| ID | Name | Type |
|---|---|---|
mmc | Mindful Money Coaching | Customer tenant (iframe-hosted) |
tsg | The Synergy Group | Internal tenant (platform-hosted) |
coachpilot | CoachPilot showcase | Internal tenant (platform-hosted) |
hsp | (Reserved for HSP) | Future tenant |
The schema permits up to 64 characters; we use short stable slugs. Renaming a tenant is a destructive operation — never do it; create a new row and migrate references.
The three layers of tenant data
┌─────────────────────────────────────────────────────────────────┐
│ Layer 1 — Platform defaults │
│ framework_definitions/money_archetypes.json │
│ Baked into gateway + agent images. Shared by ALL tenants. │
└─────────────────────────────────────────────────────────────────┘
│
▼ merged with
┌─────────────────────────────────────────────────────────────────┐
│ Layer 2 — Per-(tenant, framework) overrides │
│ framework_configs (one row per tenant per framework) │
│ 8 JSONB columns: archetype, voice, scoring, mode, landing, │
│ communications, results, providers │
└─────────────────────────────────────────────────────────────────┘
│
▼ alongside
┌─────────────────────────────────────────────────────────────────┐
│ Layer 3 — Per-tenant identity │
│ tenant_brand (one row per tenant, framework-agnostic) │
│ coach details, brand JSONB, slug_prefix, webhook URLs │
└─────────────────────────────────────────────────────────────────┘
The resolved framework that consumers fetch via /api/v1/assess/framework/{id}/resolved?client=<tenant> is Layer 1 + Layer 2 merged. Layer 3 (tenant_brand) is fetched separately via /api/v1/assess/tenant/{tenant_id}/brand.
See 04 Config schema for the per-column merge rules. See 05 Configuration flow for the PATCH lifecycle.
Per-tenant URLs — the slug_prefix discriminator
The single field that determines whether a tenant uses platform-hosted (direct) or tenant-hosted (iframe) URLs is tenant_brand.slug_prefix.
The composer logic lives in registry/framework_config.py::compose_tenant_urls and is mirrored in the dashboard preview at portal/tenant/brand/page.tsx::composePreview.
def compose_tenant_urls(brand):
slug_prefix = (brand.get("slug_prefix") or "").strip()
if slug_prefix: # MMC: 'money-'
website = (brand.get("website_url") or "").strip().rstrip("/")
base = website if website else PLATFORM_QUIZ_HOST
suffix = "/" if website else ""
else: # TSG / coachpilot / hsp
base = PLATFORM_QUIZ_HOST # https://quiz.thesynergygroup.ch
suffix = ""
return {
fmt: f"{base}/{slug_prefix}{bare}{suffix}"
for fmt, bare in FORMAT_BARE_SLUGS.items()
}
| Tenant | slug_prefix | website_url | Result for qa (Money Mirror) |
|---|---|---|---|
mmc | money- | https://mindfulmoneycoaching.online | https://mindfulmoneycoaching.online/money-mirror/ |
tsg | (empty) | https://new.thesynergygroup.ch (unused for URLs) | https://quiz.thesynergygroup.ch/mirror |
coachpilot | (empty) | https://coachpilot.ch | https://quiz.thesynergygroup.ch/mirror |
hsp | (empty when configured) | TBD | https://quiz.thesynergygroup.ch/mirror |
The composed URL map ships in the /resolved response under _tenant_urls. Consumers (e.g. WordPress shortcodes, social share generators) read this map rather than constructing URLs by hand.
canonical_quiz_url is a sibling field for the Money Quiz (Likert) format specifically, because Likert isn't part of the _tenant_urls map composition (it's a fully separate URL the tenant controls). Today only MMC has this set: https://mindfulmoneycoaching.online/money-quiz/.
How tenancy is resolved at request time
Three resolution paths depending on where the code runs:
1. Public quiz Next.js — resolveTenant(req) (uses URL)
moneyquiz-app/src/lib/tenant-config.ts:
export function resolveTenant(req: Request): string {
// Priority 1: ?tenant=<id> query param (test harnesses + admin tools)
const fromQuery = new URL(req.url).searchParams.get("tenant");
if (fromQuery) return fromQuery;
// Priority 2: Host header → HOST_TENANT_MAP
const host = (req.headers.get("host") || "").split(":")[0].toLowerCase();
return HOST_TENANT_MAP[host] || DEFAULT_TENANT;
}
The HOST_TENANT_MAP is hardcoded:
const HOST_TENANT_MAP = {
"quiz.thesynergygroup.ch": "mmc",
"assess.thesynergygroup.ch": "mmc",
};
When adding a new tenant subdomain, edit this map and the layout-level resolution picks it up on the next deploy.
2. Server components — resolveTenantFromHeaders() (uses Host only)
Server components (e.g. layout.tsx) don't receive a Request object directly. They use:
export async function resolveTenantFromHeaders(): Promise<string> {
const { headers } = await import("next/headers");
const h = await headers();
const host = (h.get("host") || "").split(":")[0].toLowerCase();
return HOST_TENANT_MAP[host] || DEFAULT_TENANT;
}
Note: this CANNOT see the ?tenant= query param. The theme/brand provider therefore always themes by host. Page-level routes (e.g. /api/quiz/results) get a Request and DO see ?tenant=.
3. Gateway — ?client=<tenant> query parameter
All gateway endpoints that need tenant context accept ?client=<tenant> explicitly. The gateway never reads a Host header for tenant resolution — too brittle. Consumers are required to pass it.
Override merge rules
Every override column in framework_configs has DIFFERENT merge semantics. This is non-obvious — see feedback_coachpilot_resolver_quirks.md.
| Column | Merge type | Notes |
|---|---|---|
archetype_overrides | Per-dimension shallow merge | Only column with per-key merge below the JSONB level. {hero: {description: "X"}} merges with platform Hero, leaving other fields intact. |
voice_overrides | Wholesale replace | Resolver sets resolved.voice = <override>. Defaults stay separate at top level. |
scoring_overrides | Wholesale replace | Tenant ships the FULL scoring block on save. |
mode_overrides | Wholesale replace per-mode | {qa: {...}} replaces only the qa mode; other modes untouched. |
landing_overrides | Wholesale replace | |
communications | Wholesale replace | |
results_display | Wholesale replace | |
providers | Wholesale replace |
Practical implication for dashboard editors:
Every editor that saves to a wholesale-replace column MUST load the platform defaults from a TS module or JSON asset and ship the FULL bundle on save. Sending a partial payload causes the resolver to set resolved.<field> to that partial, silently dropping every field not included.
The Voice editor, Scoring editor, Mode editors, Landing editor, Communications editor, Results editor, and Providers editor all follow this pattern. The Archetypes editor is the only one that can safely PATCH per-dimension partials.
Clearing an override
PATCHing with {} does NOT clear an override. Python's _shallow_merge uses partial.get(f) or current.get(f) and {} is falsy, so it falls through to current.
To actually reset:
- Restore a snapshot: use
POST /api/v1/assess/framework/{id}/restore?client=<tenant>&snapshot=<id>. - PATCH explicit defaults: send the full default value as the new override.
- Direct SQL:
UPDATE framework_configs SET <column> = '{}'::jsonb WHERE tenant_id = .... Use with care — bypasses snapshot creation.
For JSONB keys WITHIN a column, even direct SQL via PATCH can't remove sub-keys (shallow-merge can only add/overwrite). Use UPDATE ... SET col = col - 'subkey' directly:
kubectl exec postgres-0 -- psql -U coachpilot -d coachpilot \
-c "UPDATE framework_configs SET mode_overrides = mode_overrides - 'quiz' WHERE tenant_id='tsg'"
This pattern was used in C6 (2026-05-18) to clean up an orphan mode_overrides.quiz key in TSG after the rename to mode_overrides.qa.
Defense-in-depth canonicalization
The resolver applies _canonicalize_mode_keys() when emitting resolved.modes to defensively remap legacy keys:
def _canonicalize_mode_keys(modes):
remap = {"quiz": "qa", "traditional": "likert"}
out = {}
for k, v in modes.items():
canonical = remap.get(k, k)
if canonical in out:
continue # canonical wins on collision
out[canonical] = v
return out
This runs on EVERY /resolved read. Even if legacy keys resurface in DB backups or future PATCHes, consumers see canonical keys. Added in C6 after the format rename.
Tenant onboarding checklist
When adding a new tenant xyz:
-
Insert tenants row:
INSERT INTO tenants (id, name, tier, locale) VALUES ('xyz', 'Customer Display Name', 'professional', 'en'); -
Insert tenant_brand row with at minimum:
display_namecoach_name,coach_email(Resend sender — must be a verified domain)booking_url- For platform-hosted: leave
slug_prefix = '',website_url = '<their marketing site>' - For tenant-hosted: set
slug_prefix = 'xyz-'(or similar),website_url = '<their site>',canonical_quiz_url = '<their Likert page if any>'
-
Insert framework_configs row (can be empty initially):
INSERT INTO framework_configs (tenant_id, framework_id) VALUES ('xyz', 'money_archetypes'); -
Add to HOST_TENANT_MAP in tenant-config.ts if they have a dedicated subdomain that should resolve to
xyz. -
Configure webhooks (optional, for external CRM):
archetype_scores_webhook— POST endpoint receiving quiz completionsarchetype_scores_secret— shared secret (X-Deal-Secret header)coach_notification_email— separate fromcoach_emailif Resend sender ≠ inbox
-
Configure brand JSONB (optional, for theming):
{"primary": "#2E7D5B", "accent": "#FFA940", "cream": "#F8F6F1", "fontHeading": "Inter", "fontBody": "Inter"}When empty, platform defaults (Ilana palette) are used.
-
Iframe pattern only: tenant deploys their own pages that iframe
quiz.thesynergygroup.ch/?tenant=xyz. For WordPress, use the mmc-moneyquiz-bridge/auto-pages.php pattern as a template. -
Run smoke: complete a real quiz with
?tenant=xyz, verify scores stored, verify webhook fires (if configured), verify coach notification email goes to the right inbox.
Removing a tenant
There is no soft-delete or archival flow today. To remove a tenant:
DELETE FROM framework_config_snapshots WHERE tenant_id='xyz';DELETE FROM framework_configs WHERE tenant_id='xyz';DELETE FROM tenant_brand WHERE tenant_id='xyz';DELETE FROM tenants WHERE id='xyz';
This cascades cleanly because all FKs use ON DELETE CASCADE. Audit trail in assess_audit_log is retained for forensics.
Always export the tenant's data first (pg_dump --table=...) — there's no undo.
Next
→ 03 Format taxonomy — the 5 canonical formats, alias map, URL composition.