Skip to content

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:

  1. Understand what's configurable: Configuration spread across site_configs, global_client_config, agent_config table, plus hardcoded defaults in Python and TypeScript
  2. Add new features: Each new feature requires ad-hoc decisions about where to store config
  3. Build a back-office UI: No schema documentation for JSONB fields - impossible to auto-generate forms
  4. 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_config is 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:

  1. Config-level (top-level schema property): applies to the entire config slug
  2. 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:

{
  "slug": "content_gating",
  "scope": "website",
  "requires": ["agents"]
}

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:

SELECT config FROM client_configs
WHERE domain = 'acme.com' AND config_slug = 'identity'

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-access is "client", but enrich_all_visitors is "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_color is client, z_index is staff. mixed for traffic_control: traffic_percentage, enable_mobile, URL patterns are client; spa_widget_handling is 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:

  1. Determine viewer role: client or staff (Rose team)
  2. Filter configs and properties by x-access for the current role:
  3. Client view: hide x-access: "staff" configs/properties
  4. Staff view: show everything, visually distinguish staff-only fields
  5. List configs grouped by scope then category, sorted by display_order
  6. For is_feature = true: show enabled toggle
  7. Render form from config_schema using react-jsonschema-form
  8. Show "Using default" badge when client_configs.config is NULL (client hasn't overridden)
  9. Show "Custom" badge when client_configs.config is not NULL (explicit override exists)
  10. "Reset to default" button sets config = NULL
  11. 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.comacme) - 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:widget hints
  • x-access — access level ("client", "staff"). Omitted = "client". See section 4.
  • x-feature — whether this config is a toggleable feature (mirrors is_feature in DB)
  • default — factory seed values used to populate config.configs.default_config during initial setup
  • required — required field validation

What schema files do NOT contain:

  • Slug-level enabled flag — handled by DB columns enabled_by_default / client_configs.enabled
  • Runtime default values — after initial seed, the DB default_config is 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

  1. Create JSON Schema file: /schemas/configs/{scope}/{slug}.schema.json
  2. Run codegen: just codegen-configs
  3. Generates TypeScript types → frontend/shared/src/types/configs.ts
  4. Generates Pydantic models → backend/packages/ixconfig/models/configs.py
  5. Create migration to register in config.configs:
    INSERT INTO config.configs (slug, name, scope, category, is_feature, enabled_by_default, default_config)
    VALUES ('form_assistant', 'Form Assistant', 'website', 'feature', true, false,
      '{}' -- or populate from schema "default" key
    );
    

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_feature distinguishes toggleable features from settings
  • Scope-ready: scope column 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_config column later if needed
  • No per-client default templates: Only global defaults, copied on creation (e.g., AI sections CSS)
  • Content tables remain: ai_sections stays as a separate table (toggle only in configs); ctas migrates to a ctas config 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.