ADR: Unified Configuration System (Clients, Domains, Configs)¶
Status¶
Accepted — fully implemented
Date¶
2026-01-31
Context¶
The Rose platform's configuration is scattered across multiple Supabase tables and JSONB columns, making it difficult to:
- Understand what's configurable: Configuration spread across
site_configs,global_client_config,agent_configtable, plus hardcoded defaults in Python and TypeScript - Add new features: Each new feature requires ad-hoc decisions about where to store config
- Build a back-office UI: No schema documentation for JSONB fields - impossible to auto-generate forms
- Scale to multiple agents: Currently everything is implicitly for the Website Agent; Nurturing Agent and future agents will need their own configs
Current State¶
site_configs:
├── agent_config (JSONB - legacy, mixed concerns)
├── custom_config (JSONB - catch-all: disclaimers, mobile, analytics, feature flags...)
├── form_config (JSONB - form assistant)
├── dynamic_questions (JSONB)
├── traffic_allocated (INT - gradual rollout %)
├── color, name, domain... (basic identity)
└── ctas (JSONB)
global_client_config (singleton):
├── agent_config (JSONB - global defaults)
├── features (JSONB - feature flags)
└── profiling (JSONB)
agent_config table:
├── skill_toggles (JSONB) - includes content_gating
├── qualification_criteria (JSONB)
├── behavior_* columns
└── prompt_* columns
ai_sections table:
├── Per-section content (questions, title, styling, custom_css)
└── show_search_bar, search_bar_placeholder
Problems:
custom_configis a catch-all with 15+ unrelated concerns- 3-level cascade logic (site → global → default) implemented in Python, not documented
- No JSON Schema for any JSONB field
- Mobile config, URL exclusions, traffic control are separate but related (all control display)
- Client identity (name, description) mixed with feature config
- Not all configs are "features" - some are settings, some are identity
Decision¶
1. Unified Configuration System¶
Everything is a config - including identity. No separate identity table.
clients - minimal registry of clients (can have multiple domains, can be disabled)
domains - domains belonging to clients
configs - defines all configurable behaviors with JSON Schema (including identity)
client_configs - stores per-domain overrides
┌─────────────────────────────────────────────────────────────────┐
│ clients (minimal registry) │
│ - id (UUID, PK) │
│ - name ("Acme Corp" - internal reference) │
│ - enabled (false = block all access) │
│ - created_at │
└─────────────────────────────────────────────────────────────────┘
│
│ 1:N
▼
┌─────────────────────────────────────────────────────────────────┐
│ domains │
│ - domain (PK) "acme.com", "acme-product.com" │
│ - client_id (FK → clients) │
│ - created_at │
└─────────────────────────────────────────────────────────────────┘
│
│ 1:N
▼
┌─────────────────────────────────────────────────────────────────┐
│ client_configs (per-domain overrides) │
│ - domain (FK → domains) │
│ - config_slug (FK → configs) │
│ - enabled (NULL if config is not a feature) │
│ - config (JSONB) - NULL = use default │
│ - updated_at │
└─────────────────────────────────────────────────────────────────┘
│
│ N:1
▼
┌─────────────────────────────────────────────────────────────────┐
│ configs (config registry - metadata only) │
│ - slug (PK) → matches /schemas/configs/{slug}.schema.json │
│ - name, description │
│ - scope: 'website' | 'nurturing' | 'platform' │
│ - category: 'identity' | 'feature' | 'behavior' | 'appearance' | 'display' | 'analytics' │
│ - is_feature (boolean) - has enabled/disabled toggle │
│ - enabled_by_default (only applies if is_feature = true) │
│ - requires (TEXT[]) - config slugs this depends on │
│ - default_config (JSONB) │
│ - example_configs (JSONB) - sample configs for back-office │
│ - merge_strategy: 'deep_merge' | 'full_override' | 'merge_by_key' │
└─────────────────────────────────────────────────────────────────┘
│
│ Schema files (source of truth for validation + UI)
▼
┌─────────────────────────────────────────────────────────────────┐
│ /schemas/configs/*.schema.json │
│ - JSON Schema (draft-07) for each config │
│ - Used by: back-office UI, codegen (TS types, Pydantic models) │
└─────────────────────────────────────────────────────────────────┘
2. Schema Definition¶
Core entities (clients, domains) live in public schema. Configuration system (configs, client_configs) lives in config schema.
-- ============================================
-- CLIENTS & DOMAINS (public schema - core entities)
-- ============================================
-- Client registry (one client = one company, can have multiple domains)
CREATE TABLE clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE, -- Internal reference: "Acme Corp"
enabled BOOLEAN NOT NULL DEFAULT true, -- Disable = no access to anything
created_at TIMESTAMPTZ DEFAULT now()
);
-- Domains belonging to clients
CREATE TABLE domains (
domain TEXT PRIMARY KEY, -- "acme.com", "acme-product.com"
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_domains_client ON domains(client_id);
-- ============================================
-- CONFIG SCHEMA (configuration system)
-- ============================================
CREATE SCHEMA IF NOT EXISTS config;
-- Config definitions (metadata - schema lives in files)
CREATE TABLE config.configs (
slug TEXT PRIMARY KEY, -- Must match filename: /schemas/configs/{slug}.schema.json
name TEXT NOT NULL,
description TEXT,
scope TEXT NOT NULL, -- 'website', 'nurturing', 'platform'
category TEXT NOT NULL, -- 'identity', 'feature', 'behavior', 'appearance', 'display', 'analytics'
is_feature BOOLEAN NOT NULL DEFAULT false, -- true = has enabled/disabled toggle
enabled_by_default BOOLEAN DEFAULT true, -- only used if is_feature = true
requires TEXT[], -- config slugs this depends on (cross-agent dependencies)
default_config JSONB NOT NULL DEFAULT '{}',
example_configs JSONB, -- array of {name, description, config} for back-office
-- NOTE: config_schema lives in /schemas/configs/{slug}.schema.json (not in DB)
merge_strategy TEXT NOT NULL DEFAULT 'deep_merge',
visible_in_backoffice BOOLEAN DEFAULT true,
display_order INT DEFAULT 0
);
-- Client config overrides (per-domain)
CREATE TABLE config.client_configs (
domain TEXT NOT NULL REFERENCES public.domains(domain) ON DELETE CASCADE,
config_slug TEXT NOT NULL REFERENCES config.configs(slug) ON DELETE CASCADE,
enabled BOOLEAN, -- NULL if config.is_feature = false
config JSONB, -- NULL = use default_config from configs table
updated_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (domain, config_slug)
);
CREATE INDEX idx_client_configs_domain ON config.client_configs(domain);
Schema organization:
| Schema | Tables | Purpose |
|---|---|---|
public |
clients, domains |
Core business entities (used by billing, analytics, etc.) |
config |
configs, client_configs |
Configuration system (metadata + overrides) |
/schemas/configs/ |
*.schema.json files |
JSON Schema definitions (source of truth for validation + UI) |
3. Features vs Settings¶
The is_feature boolean distinguishes:
| is_feature | Meaning | enabled column |
Examples |
|---|---|---|---|
true |
Toggleable feature | Used (true/false) | engagement, qualification, content_gating, inchat_booking |
false |
Setting/config | NULL (ignored) | appearance, traffic_control, analytics |
4. Parameter Access Levels (x-access)¶
Not all parameters are meant for the same audience. Three roles interact with configuration:
| Role | Who | Access |
|---|---|---|
| Rose platform | Rose engineering team | Sets platform defaults in configs.default_config. Not visible in back-office. |
| Rose staff | Rose customer success / ops | Configures client-specific technical settings (endpoints, models, enrichment). Visible in back-office with staff role. |
| Client | Customer using back-office | Configures their own branding, content, features. Self-service in back-office. |
The x-access extension in JSON Schema controls visibility and editability:
x-access |
Meaning | Back-office behavior |
|---|---|---|
"client" |
Client-configurable | Visible and editable by client. Default when omitted. |
"staff" |
Rose staff only | Visible and editable only by Rose staff. Hidden from client view. |
Default: When x-access is not specified, it defaults to "client". This means most parameters are client-facing by default - you only annotate the exceptions.
Granularity: x-access can be set at two levels:
- Config-level (top-level schema property): applies to the entire config slug
- Property-level (individual field): overrides the config-level default for that field
{
"$id": "system",
"title": "System",
"x-access": "staff",
"properties": {
"api_endpoint": { "type": "string" },
"model": { "type": "object" }
}
}
In this example: the entire system config is "staff", so all its properties (api_endpoint, model, etc.) inherit "staff" and are only visible to Rose staff.
Resolution: property-level x-access > config-level x-access > "client" (global default).
Examples by role:
| Parameter | x-access |
Rationale |
|---|---|---|
identity.company_name |
(omitted = "client") |
Client sets their own company name |
identity.brand_color |
(omitted = "client") |
Client picks their brand color |
appearance.z_index |
"staff" |
Technical setting, client doesn't need to touch |
system.api_endpoint |
"staff" (inherited) |
Rose staff configures endpoints per client |
system.model |
"staff" (inherited) |
Rose staff picks the LLM model per client |
system.enable_new_agentic_system |
"staff" (inherited) |
Rose staff controls migration flag |
traffic_control.traffic_percentage |
(omitted = "client") |
Client controls their rollout |
traffic_control.spa_widget_handling |
"staff" |
Technical SPA config, Rose staff sets up |
qualification.enrich_all_visitors |
"staff" |
Rose staff enables enrichment per client |
engagement.dynamic_questions.questions |
(omitted = "client") |
Client writes their own starter questions |
5. Scope¶
Configs are scoped to agents or platform-wide:
| Scope | Applies To | Examples |
|---|---|---|
website |
Website Agent (widget, AI sections) | engagement, qualification, content_gating, appearance, traffic_control |
nurturing |
Nurturing Agent (email) | email_templates, send_frequency (future) |
platform |
Cross-agent, platform-level | identity, agents, agent_system |
The Widget and AI Sections are both Website Agent interfaces, so their configs belong to scope: 'website'.
Nurturing is a scope, not a config slug. Whether the nurturing agent is enabled/disabled is controlled via the agents config (e.g., {"nurturing-agent": {"enabled": true}}).
6. Config Categories¶
| Category | Purpose | Examples |
|---|---|---|
identity |
Brand/company information | identity (company_name, brand_color, logo, etc.) |
feature |
Toggleable functionality | engagement, form_assistant, content_gating, inchat_booking, qualification |
appearance |
Visual customization | appearance (brand_color, z_index, disclaimers, conversation_display_mode) |
display |
When/where to show | traffic_control (includes mobile, URL patterns) |
analytics |
Tracking & classification | analytics (UTM, form_tracking, taxonomy, parent_window_events) |
content |
Content definitions | ctas |
system |
Agent infrastructure | agent_system (endpoints, model, knowledge_source) |
7. Merge Strategies¶
| Strategy | Behavior | Use Case |
|---|---|---|
deep_merge |
Recursively merge nested objects | Appearance, display settings |
full_override |
Replace entirely | Interest signals, forms, qualification criteria |
merge_by_key |
Override specific language keys | Dynamic questions, disclaimers |
8. Cross-Agent Dependencies¶
Some features span multiple agents. The requires field declares dependencies:
Example: Content Gating
Content gating collects emails via the website agent (in chat), but feeds the nurturing agent (email sequences). It only makes sense if the nurturing agent is enabled in the agents config.
Resolution logic checks dependencies before enabling:
# Check dependencies first
if config_def.requires:
for required_slug in config_def.requires:
required_enabled, _ = get_config(site_domain, required_slug)
if not required_enabled:
return (False, None) # Dependency not met
9. Example Configs¶
Some configs have no meaningful default but benefit from examples. The example_configs field provides sample configurations for the back-office:
{
"slug": "engagement",
"default_config": {
"dynamic_questions": { "enabled": true },
"suggested_answers": { "enabled": true },
"followup_suggestions": { "enabled": true }
},
"example_configs": [
{
"name": "With custom answer rules",
"description": "Custom suggested answer generation rules",
"config": {
"dynamic_questions": { "enabled": true },
"suggested_answers": {
"enabled": true,
"rules": "Keep answers under 3 sentences. Always end with a question."
},
"followup_suggestions": { "enabled": true }
}
}
]
}
Back-office UI:
- Shows "No configuration set" when config is NULL
- Dropdown: "Start from example" → copies example into editor
- Client edits their copy
10. Frontend Access Pattern¶
Identity is now a config like everything else. Frontend reads it the same way:
For all configs, two options:
| Pattern | When to Use |
|---|---|
Direct read from client_configs |
Simple configs (identity, traffic_control, appearance) |
API endpoint /api/config/{domain} |
Complex configs needing backend merge logic |
Both are valid. The backend ConfigResolver handles the merge logic and can expose resolved configs via API.
11. Traffic Control Config¶
Consolidates all display-related settings into one config:
{
"slug": "traffic_control",
"scope": "website",
"category": "display",
"is_feature": false,
"default_config": {
"traffic_percentage": 100,
"widget_display_mode": "always",
"enable_mobile": false,
"mobile_breakpoint": 768,
"exclude_url_patterns": [],
"display_only_on_urls": [],
"hide_on_cta_pages": false
},
"config_schema": {
"type": "object",
"properties": {
"traffic_percentage": {
"type": "integer",
"minimum": 0,
"maximum": 100,
"ui:description": "Percentage of visitors who see the widget"
},
"widget_display_mode": {
"type": "string",
"enum": ["normal", "hidden_zone_click"],
"ui:description": "How widget appears: normal (always visible) or hidden_zone_click (click to reveal)"
},
"enable_mobile": {
"type": "boolean",
"ui:description": "Show widget on mobile devices"
},
"mobile_breakpoint": {
"type": "integer",
"ui:description": "Screen width below which is considered mobile"
},
"exclude_url_patterns": {
"type": "array",
"items": { "type": "string" },
"ui:description": "URL patterns where widget is hidden"
},
"display_only_on_urls": {
"type": "array",
"items": { "type": "string" },
"ui:description": "If set, widget only shows on these URLs"
},
"hide_on_cta_pages": {
"type": "boolean",
"ui:description": "Hide widget on CTA destination pages"
}
}
}
}
12. Content Gating Feature¶
Content gating (gate content behind email collection) becomes a first-class feature. It lives in the website agent (executes in chat) but requires the nurturing agent to be configured (emails collected feed nurturing sequences).
{
"slug": "content_gating",
"scope": "website",
"category": "feature",
"is_feature": true,
"enabled_by_default": false,
"requires": ["nurturing_agent"],
"default_config": {
"gate_after_messages": 3,
"gated_content_types": ["case_studies", "whitepapers", "detailed_pricing"],
"require_email": true,
"require_company": false
},
"config_schema": {
"type": "object",
"properties": {
"gate_after_messages": {
"type": "integer",
"minimum": 1,
"ui:description": "Number of messages before gating content"
},
"gated_content_types": {
"type": "array",
"items": { "type": "string" },
"ui:description": "Content types that require email to access"
},
"require_email": {
"type": "boolean",
"ui:description": "Require email to unlock gated content"
},
"require_company": {
"type": "boolean",
"ui:description": "Also require company name"
}
}
}
}
13. Qualification Config (includes Form Collection)¶
The qualification config consolidates the full visitor qualification funnel: enrichment, interest signals, qualification criteria, and form field collection (formerly form_router).
Form collection enables the agent to skip funnel steps by extracting form field values from conversation context and returning pre-filled form URLs. This helps visitors bypass repetitive form questions when the agent already knows the answers.
Use case: A visitor discussing their company size can be sent directly to signup?employees=15_plus instead of a generic signup page.
{
"slug": "qualification",
"scope": "website",
"category": "feature",
"is_feature": true,
"enabled_by_default": true,
"merge_strategy": "deep_merge",
"default_config": {
"enrich_all_visitors": false,
"interest_signals": { "signals": [], "threshold": null },
"criteria": {},
"form_collection": { "enabled": true, "forms": {} }
},
"config_schema": {
"type": "object",
"properties": {
"forms": {
"type": "object",
"ui:description": "Form definitions keyed by form ID",
"additionalProperties": {
"type": "object",
"required": ["id", "name", "cta_id", "fields", "fallback_url", "cta_url_template"],
"properties": {
"id": {
"type": "string",
"ui:description": "Unique form identifier"
},
"name": {
"type": "string",
"ui:description": "Human-readable form name"
},
"cta_id": {
"type": "string",
"ui:description": "CTA that triggers this form (links to ctas[].cta_id)"
},
"fields": {
"type": "array",
"ui:description": "Fields to extract from conversation",
"items": {
"type": "object",
"required": ["id", "type", "extraction_prompt"],
"properties": {
"id": {
"type": "string",
"ui:description": "Field ID used in URL template"
},
"type": {
"type": "string",
"enum": ["single_choice", "text", "email", "number"],
"ui:description": "Field type for validation"
},
"description": {
"type": "string",
"ui:description": "Human-readable field description"
},
"extraction_prompt": {
"type": "string",
"ui:widget": "textarea",
"ui:description": "LLM prompt for extracting this field from conversation"
},
"options": {
"type": "array",
"ui:description": "Valid options for single_choice fields",
"items": {
"type": "object",
"properties": {
"label": { "type": "string" },
"value": { "type": "string" }
}
}
},
"required": {
"type": "boolean",
"ui:description": "Whether field must be extracted before using template URL"
},
"priority": {
"type": "integer",
"ui:description": "Extraction priority (lower = extracted first)"
}
}
}
},
"cta_url_template": {
"type": "string",
"format": "uri-template",
"ui:description": "URL template with {{field_id}} placeholders"
},
"fallback_url": {
"type": "string",
"format": "uri",
"ui:description": "URL to use when required fields cannot be extracted"
}
}
}
}
}
}
}
Example config (migrated from site_configs.form_config):
{
"forms": {
"signup_form": {
"id": "signup_form",
"name": "Signup Form",
"cta_id": "start-now",
"fields": [
{
"id": "company_employee_count",
"type": "single_choice",
"options": [
{ "label": "1 (independent)", "value": "independant" },
{ "label": "1-5 employees", "value": "1_a_5" },
{ "label": "6-15 employees", "value": "6_a_15" },
{ "label": "15+ employees", "value": "15_plus" }
],
"priority": 1,
"required": true,
"description": "Number of employees in the company",
"extraction_prompt": "Based on the conversation, determine the company size. Return one of: 'independant' (1 person), '1_a_5' (1-5 employees), '6_a_15' (6-15 employees), '15_plus' (more than 15). Return null if not mentioned."
}
],
"fallback_url": "https://start.example.com/users/sign_up",
"cta_url_template": "https://start.example.com/users/sign_up?entreprise={{company_employee_count}}"
}
}
}
14. Appearance Config (includes Disclaimers)¶
The appearance config consolidates all visual presentation: brand color, z-index, disclaimers, and conversation display mode.
{
"slug": "appearance",
"scope": "website",
"category": "appearance",
"is_feature": false,
"merge_strategy": "deep_merge",
"default_config": {
"brand_color": "#0A42C3",
"z_index": 9999,
"disclaimers": {
"always_show": false,
"text": {
"en": { "ai": "AI-generated replies", "poweredBy": "Powered by" },
"fr": { "ai": "Réponses générées par IA", "poweredBy": "Propulsé par" }
}
},
"conversation_display_mode": "default"
}
}
15. Analytics Config (includes Taxonomy, Form Tracking, Event Forwarding)¶
The analytics config consolidates all tracking/classification: UTM settings, topic taxonomy, form tracking pages, and parent window event forwarding.
{
"slug": "analytics",
"scope": "website",
"category": "analytics",
"is_feature": false,
"default_config": {
"session_utm_name": null,
"login_urls": [],
"taxonomy": { "topics": [] },
"form_tracking_pages": [],
"parent_window_events": { "enabled": true }
}
}
16. Identity Config¶
Identity is a config like everything else. Each domain has its own identity config (a client with multiple domains can have different branding per domain).
{
"slug": "identity",
"scope": "platform",
"category": "identity",
"is_feature": false,
"default_config": {
"company_name": "",
"website_url": "",
"description": "",
"brand_color": "#0A42C3",
"logo_url": null,
"display_name": "",
"default_language": "en"
},
"config_schema": {
"type": "object",
"required": ["company_name", "brand_color"],
"properties": {
"company_name": {
"type": "string",
"ui:description": "Company name for display"
},
"website_url": {
"type": "string",
"format": "uri",
"ui:description": "Main website URL"
},
"description": {
"type": "string",
"ui:widget": "textarea",
"ui:description": "Company description for LLM context"
},
"brand_color": {
"type": "string",
"format": "color",
"ui:widget": "color",
"ui:description": "Primary brand color (hex)"
},
"logo_url": {
"type": "string",
"format": "uri",
"ui:description": "Logo URL"
},
"display_name": {
"type": "string",
"ui:description": "Display name (if different from company_name)"
},
"default_language": {
"type": "string",
"enum": ["en", "fr", "es", "de", "it"],
"ui:description": "Default language for the widget"
}
}
}
}
17. AI Sections: Hybrid Approach¶
AI Sections uses both patterns:
| What | Where | Reason |
|---|---|---|
| Feature enabled | client_configs |
Toggle per client |
| Default CSS/styling template | configs.default_config |
Global only, copied to new sections |
| Section content | ai_sections table |
Per-section (questions, title, custom_css, show_search_bar, etc.) |
When creating a new section, copy default_styling and default_custom_css from configs.default_config into the new ai_sections row. Client then edits their copy.
18. Complete Config Inventory (13 Slugs)¶
The original design had 26 individual slugs. After codebase analysis, these were consolidated to 13 based on logical groupings (what admin concern they serve):
| Consolidation | Absorbs | Rationale |
|---|---|---|
engagement |
dynamic_questions + suggested_answers + followup_suggestions | All LLM-driven conversation elements |
qualification |
visitor_enrichment + interest_signals + qualification_criteria + form_router | Full qualification funnel: enrich → detect → evaluate → collect |
appearance |
widget_appearance + disclaimers + conversation_display | All visual presentation |
analytics |
analytics + form_tracking + taxonomy + parent_window_events | All tracking/classification |
agent_system |
website_agent_system + website_agent_evaluation + knowledge_source | All agent infrastructure |
~~nurturing_agent~~ |
removed | Nurturing is a scope, toggle lives in agents |
Platform Scope (Cross-Agent)¶
| Slug | Category | is_feature | x-access | Merge | Migrates From |
|---|---|---|---|---|---|
identity |
identity | false | client | deep_merge | site_configs (name, color, etc.) + agent_config (company_name, description) |
agents |
feature | false | staff | deep_merge | agent_config.agents_override |
agent_system |
system | false | staff | deep_merge | site_configs.api_endpoint/model/knowledge + evaluation_endpoint/conversation_endpoint |
Website Agent - Features (is_feature = true)¶
| Slug | Category | Enabled by Default | x-access | Requires | Migrates From |
|---|---|---|---|---|---|
engagement |
feature | true | client | - | site_configs.dynamic_questions + agent_config.behavior_suggested_answers_rules + custom_config.disable_followup_suggestions |
form_assistant |
feature | false | client | - | custom_config.form_assistant |
inchat_booking |
feature | false | client | - | custom_config.booking_workflow |
ai_sections |
feature | true | client | - | ai_sections table (content stays) |
content_gating |
feature | false | staff | agents |
agent_config.skill_toggles.content_gating |
qualification |
feature | true | mixed | - | global_client_config.features.enrich_all_visitors + agent_config.behavior_interest_signals + agent_config.qualification_criteria + site_configs.form_config |
mixed: Config-level
x-accessis"client", butenrich_all_visitorsis"staff"at property level. Most qualification fields (criteria, forms) are client-editable.
Website Agent - Settings (is_feature = false)¶
| Slug | Category | x-access | Merge | Migrates From |
|---|---|---|---|---|
appearance |
appearance | mixed | deep_merge | site_configs.color + custom_config.z_index/disclaimers/conversation_display_mode |
traffic_control |
display | mixed | deep_merge | site_configs.traffic_allocated + custom_config.enable_mobile/exclude_url_patterns/widget_display_mode |
analytics |
analytics | client | deep_merge | site_configs.analytics/taxonomy/session_utm_name + custom_config.form_tracking/parent_window_events |
ctas |
content | client | full_override | site_configs.ctas |
mixed for
appearance:brand_coloris client,z_indexis staff. mixed fortraffic_control:traffic_percentage,enable_mobile, URL patterns are client;spa_widget_handlingis staff.
19. Localization¶
All localized content inside JSONB with language keys:
{
"questions": {
"en": ["How can I help you today?"],
"fr": ["Comment puis-je vous aider ?"]
},
"disclaimers": {
"en": { "ai": "AI-generated", "poweredBy": "Powered by" },
"fr": { "ai": "Généré par IA", "poweredBy": "Propulsé par" }
}
}
20. Resolution Logic¶
def get_config(domain: str, config_slug: str) -> tuple[bool | None, dict | None]:
"""
Returns (enabled, config) tuple.
- enabled is None if config.is_feature = false
- enabled is bool if config.is_feature = true
- config is None if feature is disabled, client disabled, or dependencies not met
"""
# Check client is enabled first
client = db.get_client_for_domain(domain)
if not client or not client.enabled:
return (False, None) # Client disabled or not found
config_def = db.get_config(config_slug)
override = db.get_client_config(domain, config_slug)
# Check cross-agent dependencies
if config_def.requires:
for required_slug in config_def.requires:
required_enabled, _ = get_config(domain, required_slug)
if not required_enabled:
return (False, None) # Dependency not met
# Determine enabled status (only for features)
if config_def.is_feature:
if override is None:
enabled = config_def.enabled_by_default
else:
enabled = override.enabled
if not enabled:
return (False, None)
else:
enabled = None # Not a feature, no toggle
# Determine config value
if override is None or override.config is None:
resolved_config = config_def.default_config
else:
resolved_config = merge(
config_def.default_config,
override.config,
config_def.merge_strategy
)
return (enabled, resolved_config)
21. Back-Office UI Generation¶
The config_schema (JSON Schema) enables auto-generation:
- Determine viewer role: client or staff (Rose team)
- Filter configs and properties by
x-accessfor the current role: - Client view: hide
x-access: "staff"configs/properties - Staff view: show everything, visually distinguish staff-only fields
- List configs grouped by
scopethencategory, sorted bydisplay_order - For
is_feature = true: showenabledtoggle - Render form from
config_schemausing react-jsonschema-form - Show "Using default" badge when
client_configs.configis NULL (client hasn't overridden) - Show "Custom" badge when
client_configs.configis not NULL (explicit override exists) - "Reset to default" button sets
config = NULL - Validate against schema on save
Custom UI hints via ui: and x- prefixes in schema:
{
"type": "string",
"ui:widget": "code",
"ui:options": { "language": "css" },
"ui:description": "Custom CSS for the widget",
"x-access": "staff"
}
Staff view extras:
- Staff-only fields are visually grouped or badged (e.g., "Staff only" label)
- Staff can see which values come from configs.default_config vs client_configs.config
- Staff can edit both "staff" and "client" fields; clients can only edit "client" fields
22. Helper Function: Create Domain¶
Convenience function to create a domain with optional client auto-creation:
CREATE OR REPLACE FUNCTION create_domain(
p_domain TEXT,
p_client_name TEXT DEFAULT NULL, -- Optional, derived from domain if not provided
p_company_name TEXT DEFAULT NULL,
p_brand_color TEXT DEFAULT '#0A42C3'
) RETURNS UUID AS $$
DECLARE
v_client_id UUID;
v_client_name TEXT;
BEGIN
-- Derive client name from domain if not provided
-- "acme.com" → "acme", "www.acme-corp.io" → "acme-corp"
v_client_name := COALESCE(p_client_name,
regexp_replace(split_part(p_domain, '.', 1), '^www$', split_part(p_domain, '.', 2))
);
-- Get or create client (public schema)
INSERT INTO clients (name)
VALUES (v_client_name)
ON CONFLICT (name) DO NOTHING;
SELECT id INTO v_client_id FROM clients WHERE name = v_client_name;
-- Create domain (public schema)
INSERT INTO domains (domain, client_id) VALUES (p_domain, v_client_id);
-- Set identity config (config schema)
INSERT INTO config.client_configs (domain, config_slug, config)
VALUES (p_domain, 'identity', jsonb_build_object(
'company_name', COALESCE(p_company_name, v_client_name),
'brand_color', p_brand_color,
'website_url', 'https://' || p_domain
));
RETURN v_client_id;
END;
$$ LANGUAGE plpgsql;
Usage:
-- Minimal - creates client "acme" automatically
SELECT create_domain('acme.com');
-- With explicit client name (reuses existing client if found)
SELECT create_domain('acme-product.com', 'acme');
-- Full customization
SELECT create_domain('acme.com', 'Acme Corp', 'Acme Corporation', '#FF5733');
Behavior:
- If p_client_name is NULL, derives it from domain (e.g., acme.com → acme)
- If client exists (by name), reuses it; otherwise creates new client
- Sets up identity config with sensible defaults
- All other configs use global defaults (no client_configs rows needed)
23. Config Definition Management¶
Config definitions are split between schema files (structure) and database (runtime values).
Separation of Concerns¶
| Concern | Where | Changed When |
|---|---|---|
Structure (types, constraints, ui: / x- hints) |
Schema files in monorepo | Structural changes only (deploy) |
| Feature on/off | config.configs.enabled_by_default |
Operational (SQL, instant) |
| Global default values | config.configs.default_config JSONB |
Operational (SQL, instant) |
| Per-domain feature on/off | config.client_configs.enabled |
Operational (SQL, instant) |
| Per-domain config values | config.client_configs.config JSONB |
Operational (SQL, instant) |
| Type safety (TS + Python) | Generated from schema files | Codegen after schema changes |
| Back-office form rendering | Schema files (bundled at build) | Deploy after schema changes |
Key principle: Operational changes (enabling features, changing defaults) go directly to the database — no deploy needed. Schema files are only touched for structural changes (adding properties, changing types, modifying constraints).
Schema Files — Structure + Factory Seed¶
Schema files define what a config looks like and provide initial seed values via the "default" key.
File structure:
/schemas/configs/
├── global/
│ ├── identity.schema.json
│ ├── agents.schema.json
│ └── agent_system.schema.json
└── website/
├── engagement.schema.json
├── form_assistant.schema.json
├── inchat_booking.schema.json
├── ai_sections.schema.json
├── content_gating.schema.json
├── qualification.schema.json
├── appearance.schema.json
├── traffic_control.schema.json
├── analytics.schema.json
└── ctas.schema.json
What schema files contain:
properties— field types, constraints, enums,ui:description/ui:widgethintsx-access— access level ("client","staff"). Omitted ="client". See section 4.x-feature— whether this config is a toggleable feature (mirrorsis_featurein DB)default— factory seed values used to populateconfig.configs.default_configduring initial setuprequired— required field validation
What schema files do NOT contain:
- Slug-level
enabledflag — handled by DB columnsenabled_by_default/client_configs.enabled - Runtime default values — after initial seed, the DB
default_configis the source of truth and can diverge
Default Value Handling¶
After the initial seed migration populates default_config from schema "default" keys, the database is the runtime source of truth. Schema files and DB are intentionally independent at runtime.
Feature toggle (slug-level on/off):
-- Enable a feature for all clients (instant, no deploy)
UPDATE config.configs SET enabled_by_default = true WHERE slug = 'content_gating';
-- Disable a feature for all clients
UPDATE config.configs SET enabled_by_default = false WHERE slug = 'form_assistant';
Sub-feature toggle (inside JSONB):
-- Change a sub-feature default for all clients
UPDATE config.configs
SET default_config = jsonb_set(default_config, '{followup_suggestions,enabled}', 'false')
WHERE slug = 'engagement';
-- Change the number of dynamic questions globally
UPDATE config.configs
SET default_config = jsonb_set(default_config, '{dynamic_questions,number_of_questions}', '5')
WHERE slug = 'engagement';
Per-domain override:
-- Enable a feature for one domain only
INSERT INTO config.client_configs (domain, config_slug, enabled, config)
VALUES ('acme.com', 'content_gating', true, '{}')
ON CONFLICT (domain, config_slug) DO UPDATE SET enabled = true;
-- Override sub-feature for one domain
INSERT INTO config.client_configs (domain, config_slug, config)
VALUES ('acme.com', 'engagement', '{"followup_suggestions": {"enabled": false}}')
ON CONFLICT (domain, config_slug)
DO UPDATE SET config = EXCLUDED.config;
Resolution at runtime: final_config = deep_merge(configs.default_config, client_configs.config)
Workflow for Adding a New Config¶
- Create JSON Schema file:
/schemas/configs/{scope}/{slug}.schema.json - Run codegen:
just codegen-configs - Generates TypeScript types →
frontend/shared/src/types/configs.ts - Generates Pydantic models →
backend/packages/ixconfig/models/configs.py - Create migration to register in
config.configs:
How Back-Office Uses Schemas¶
// Back-office imports schema files directly (bundled at build time)
import engagementSchema from '@/schemas/configs/website/engagement.schema.json';
// Or dynamic import by slug
const schema = await import(`@/schemas/configs/${scope}/${slug}.schema.json`);
// Render form with react-jsonschema-form
<Form schema={schema} formData={clientConfig} />
Benefits¶
- Schema files = source of truth for structure (types, constraints, UI hints)
- DB = source of truth for runtime values (defaults, toggles, overrides)
- Operational changes (feature toggles, default values) are instant SQL — no deploy
- Codegen = type safety in TypeScript + Python
- Back-office reads schema files directly (no DB round-trip for structure)
- All structural changes reviewable in PRs
Migration Phases¶
| Phase | Scope | Description |
|---|---|---|
| 1 | Schema | Create clients, domains, configs, and client_configs tables |
| 2 | Files | Create JSON Schema files in /schemas/configs/ |
| 3 | Codegen | Set up TypeScript + Pydantic codegen from schema files |
| 4 | Seed | Populate config.configs with metadata (no schema column) |
| 5 | Migrate | Script to extract from site_configs, custom_config, agent_config, etc. → client_configs |
| 6 | Backend | Create ConfigResolver to replace AgentConfigResolver, update SupabaseManager |
| 7 | Frontend | Update SiteConfigManager to read from new structure |
| 8 | Deprecate | Stop writing to old tables (keep reading as fallback during transition) |
| 9 | Cleanup | Drop site_configs, global_client_config, old agent_config columns |
| 10 | Back-office | Build UI that imports schema files and generates forms |
Consequences¶
Positive¶
- Unified system: Everything is a config - identity, features, settings, all in one system
- Client management: Disable a client → all domains blocked instantly
- Multi-domain support: One client can have multiple domains with different configs per domain
- Self-documenting: JSON Schema describes structure, types, constraints
- Auto-generated UI: Back-office reads schema → renders form → no custom code per config
- Features vs settings:
is_featuredistinguishes toggleable features from settings - Scope-ready:
scopecolumn allows filtering configs by scope (website, nurturing, platform) - Clean cascade: Simple 2-level: client override → global default
- Validation: JSON Schema validates configs before save
Negative¶
- Migration effort: Requires extracting data from multiple existing tables
- Dual-read period: During migration, code must read from both old and new locations
- Schema maintenance: JSON Schemas must be kept up-to-date with config changes
Neutral¶
- No versioning/rollback: Decided against for now - Supabase PITR is sufficient. Can add
previous_configcolumn later if needed - No per-client default templates: Only global defaults, copied on creation (e.g., AI sections CSS)
- Content tables remain:
ai_sectionsstays as a separate table (toggle only in configs);ctasmigrates to actasconfig slug - Per-domain configs: Configs are per-domain, not per-client, for flexibility (different domains can have different branding)
Alternatives Considered¶
1. Keep Expanding JSONB Columns¶
Continue adding fields to custom_config and agent_config.
Rejected because: Already unmanageable; no schema; can't auto-generate UI; doesn't scale to multiple agents.
2. One Table Per Feature¶
Create dynamic_questions_config, form_assistant_config, etc.
Rejected because: Table proliferation; harder to query all configs for a client; more migrations per feature.
3. External Feature Flag Service (LaunchDarkly, etc.)¶
Use a dedicated feature flag platform.
Rejected because: Overkill for config (not just flags); adds external dependency; doesn't solve schema/UI problem.
4. TOML/YAML Files in Repo¶
Store configs in version-controlled files.
Rejected because: Requires deployment to change config; clients can't self-serve in back-office.
5. Separate Identity Table¶
Keep site_configs as a separate identity table, only use configs for behavioral configuration.
Rejected because: Creates two systems to maintain. Identity can be read from client_configs just as easily. Unifying everything in one system simplifies the architecture, back-office UI, and resolution logic. The clients table provides client-level management (enable/disable, multi-domain) while all configuration (including identity) goes through the same configs system.