Skip to content

Unified Config System — Developer Guide

Overview

All Rose platform configuration lives in a single unified system. Identity, features, settings, and appearance are all "configs" — defined by JSON Schema files, stored in the database, and resolved at runtime via ConfigResolver.

Key principle: Schema files are the source of truth for structure (types, constraints, UI hints). The database is the source of truth for runtime values (defaults, toggles, overrides).

Architecture at a Glance

flowchart TB Schema["schemas/configs/scope/slug.schema.json Source of truth — you edit this"] Schema -->|"just codegen"| Codegen subgraph Codegen [Generated Code] direction LR Py["Python models/generated.py registry_generated.py accessors_generated.py"] TS["TypeScript configs.generated.ts registry.generated.ts ConfigResolver.generated.ts"] end Schema -->|"Vite glob import"| Backoffice["Backoffice UI Auto-discovers schemas at build time"] Schema -->|"default key to migration"| ConfigsDB subgraph DB [Supabase Database] direction LR ConfigsDB["config.configs slug, scope, is_feature enabled_by_default default_config JSONB merge_strategy"] ClientDB["config.client_configs domain, config_slug enabled bool or null config JSONB override"] end ConfigsDB --> Resolver ClientDB --> Resolver Resolver["ConfigResolver resolver.identity returns IdentityConfig resolver.engagement returns EngagementConfig or None"] style Schema fill:#fff3cd,stroke:#d4a017 style Codegen fill:#f0f4ff,stroke:#4a6fa5 style DB fill:#d4edda,stroke:#28a745 style Resolver fill:#e8daef,stroke:#7d3c98 style Backoffice fill:#fce4ec,stroke:#c0392b

Key Directories and Files

Path Purpose
schemas/configs/global/ Global-scope schema files (identity, agents)
schemas/configs/website/ Website-scope schema files (11 slugs)
schemas/generate.py Codegen script (Python + TypeScript from schemas)
schemas/validate.py Schema validation + slug cross-check
schemas/justfile Commands: validate, codegen, list, check-slugs
backend/packages/ixconfig/ Python package: ConfigResolver, models, merge logic
backend/packages/ixconfig/ixconfig/models/generated.py Generated Pydantic models (DO NOT EDIT)
backend/packages/ixconfig/ixconfig/registry_generated.py Generated slug-to-model map
backend/packages/ixconfig/ixconfig/accessors_generated.py Generated typed accessor mixin
backend/packages/ixconfig/ixconfig/resolver.py ConfigResolver (resolution logic, merge, caching)
frontend/shared/src/types/configs.generated.ts Generated TypeScript interfaces
frontend/client-backoffice/src/config/schemas.ts Vite glob import — auto-discovers all schemas
frontend/client-backoffice/src/components/config/ConfigSection.tsx Schema-driven form component

Current Config Slugs (13 total)

Slug Scope Feature? Access Description
identity global No client Company name, logo, language
agents global No staff Agent registry (web, nurturing, etc.)
system website No staff API endpoints, LLM model selection
appearance website No mixed Brand color, z-index, disclaimers
traffic_control website No mixed Traffic %, mobile, URL exclusions
analytics website No client UTM, taxonomy, form tracking
ctas website No client Call-to-action buttons
engagement website Yes client Dynamic questions, suggested answers
qualification website Yes mixed Interest signals, form collection
form_assistant website Yes client Form copilot mode (toggle only)
inchat_booking website Yes client In-chat booking (toggle only)
ai_sections website Yes client AI sections (toggle only, content managed separately)
content_gating website Yes staff Email gating (requires agents)

Step-by-Step: Adding a New Property to an Existing Config

Example: Add a show_logo boolean to the appearance config.

1. Edit the schema file

File: schemas/configs/website/appearance.schema.json

Add the property to properties and its default to default:

{
  "properties": {
    "brand_color": { ... },
    "z_index": { ... },
    "disclaimers": { ... },
    "show_logo": {
      "type": "boolean",
      "ui:description": "Display the company logo in the widget header"
    }
  },
  "default": {
    "brand_color": "#0A42C3",
    "z_index": 9999,
    "disclaimers": { ... },
    "show_logo": true
  }
}

2. Run codegen

cd schemas && just codegen

This regenerates all 6 output files (3 Python, 3 TypeScript). The new property appears as a typed field on AppearanceConfig.

3. Verify types

cd backend && just mypy packages/ixconfig

4. Use it in backend code

resolver = await resolve_config_for_domain("acme.com")
appearance = resolver.appearance
if appearance.show_logo:
    ...

5. Done

The backoffice UI picks up the new field automatically — no frontend code changes needed. The Vite glob import re-reads schema files at build time, and SchemaFieldRenderer renders the appropriate input widget based on the JSON Schema type.

Step-by-Step: Adding a Brand New Config Slug

Example: Add a notifications config under the website scope.

1. Create the schema file

File: schemas/configs/website/notifications.schema.json

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "$id": "notifications",
  "title": "Notifications",
  "description": "Configure notification behavior for the website agent.",
  "type": "object",
  "x-feature": true,
  "properties": {
    "email_on_new_lead": {
      "type": "boolean",
      "ui:description": "Send an email when a new lead is identified"
    },
    "webhook_url": {
      "type": ["string", "null"],
      "format": "uri",
      "ui:description": "Webhook URL to notify on events"
    }
  },
  "default": {
    "email_on_new_lead": true,
    "webhook_url": null
  }
}

Required top-level fields: $schema, $id, title, type (must be "object"), default (must be an object).

2. Validate the schema

cd schemas && just validate

3. Run codegen

cd schemas && just codegen

This generates NotificationsConfig in Pydantic and TypeScript, adds it to the slug registry, and creates a typed resolver.notifications accessor (returning NotificationsConfig | None since x-feature: true).

4. Create a Supabase migration

cd supabase && supabase migration new add_notifications_config

In the migration file:

INSERT INTO config.configs (slug, scope, name, description, category, is_feature, enabled_by_default, default_config, merge_strategy)
VALUES (
  'notifications',
  'website',
  'Notifications',
  'Configure notification behavior for the website agent.',
  'feature',
  true,
  false,
  '{"email_on_new_lead": true, "webhook_url": null}',
  'deep_merge'
);

The default_config value should match the "default" key in the schema file.

5. Verify slugs match

cd schemas && just check-slugs

This cross-references schema files against the seed migration to ensure they match.

6. Use it

Backend:

resolver = await resolve_config_for_domain("acme.com")
notifications = resolver.notifications  # NotificationsConfig | None
if notifications:
    if notifications.email_on_new_lead:
        ...

Backoffice: The new config appears automatically in Settings under "Website Agent". No frontend code changes needed — the Vite glob import discovers the new schema file at build time.

JSON Schema Reference

All schemas use JSON Schema draft-07.

Standard JSON Schema Keywords

Keyword Purpose Codegen effect
type Field type (string, integer, number, boolean, object, array) Maps to Python/TS type
type (array) Nullable: ["string", "null"] str \| None / string \| null
properties Object fields Sub-model class in Pydantic, sub-interface in TS
required Required fields Field without default in Pydantic
enum Closed value set Literal["a", "b"] / "a" \| "b"
default Root-level only — factory seed values Used by ConfigResolver as fallback
minimum / maximum Numeric bounds Field(ge=..., le=...)
minLength / maxLength String length bounds Field(min_length=..., max_length=...)
maxItems Array size limit
pattern Regex constraint (e.g., ^#[0-9A-Fa-f]{6}$ for hex colors)
format "uri", "uri-template" Hints for UI rendering
additionalProperties Dynamic keys (e.g., language maps) dict[str, X] / Record<string, X>

Custom Extensions

Extension Level Purpose Values
x-feature Schema root Marks config as a toggleable feature true (omit for always-on settings)
x-access Schema root or property Access control for backoffice visibility "staff" (omit for client-visible)
x-manage-link Schema root Show a "Manage" link instead of inline form URL path (e.g., "/ai-sections")
ui:description Property Human-readable label shown in backoffice, Pydantic Field(description=...) Free text
ui:widget Property Override the default input widget "textarea", "color", "dynamic-questions"

How Types Map to UI Widgets

Schema type Default widget Override with
boolean Switch toggle
string Text input "textarea", "color"
string + enum Select dropdown
string + format: "uri" URL input
integer / number Number input (with min/max)
array of strings String list editor (add/remove)
array of objects Collapsible object list
object with properties Nested form (recursive)
object with additionalProperties Map editor (add/remove keys)
object with all-string additionalProperties sub-fields Language group editor

Nested Objects

An object property with its own properties generates a sub-model:

{
  "properties": {
    "spa_widget_handling": {
      "type": "object",
      "properties": {
        "enabled": { "type": "boolean" },
        "url_patterns": { "type": "array", "items": { "type": "string" } }
      }
    }
  }
}

Generates: TrafficControlSpaWidgetHandling (Pydantic) and TrafficControlSpaWidgetHandling (TypeScript), named by combining the parent config class with the property name in PascalCase.

Setting Default Values

The "default" key at the schema root is mandatory and serves two purposes:

  1. Database seed: The migration copies these values into config.configs.default_config.
  2. Codegen fallback: ConfigResolver uses schema defaults to fill missing fields when resolving.
{
  "default": {
    "email_on_new_lead": true,
    "webhook_url": null,
    "rules": {
      "max_retries": 3
    }
  }
}

Important: After initial migration, the database default_config column is the runtime source of truth. Changing the schema "default" key alone does NOT update existing database rows — you need a migration for that.

Backoffice override diffing

The backoffice computes computeOverride(formValues, schemaDefaults) before saving. If the form values are identical to the schema defaults, it saves config = null (meaning "use defaults"). Only differences are stored. This keeps the client_configs table minimal.

Enabling a Feature in the Backoffice

Schema side

Add "x-feature": true at the schema root:

{
  "$id": "notifications",
  "x-feature": true,
  "properties": { ... },
  "default": { ... }
}

This makes the generated accessor return T | None instead of T, and the backoffice renders a toggle switch.

Database side

In the config.configs seed migration:

INSERT INTO config.configs (slug, scope, ..., is_feature, enabled_by_default, ...)
VALUES ('notifications', 'website', ..., true, false, ...);
  • is_feature = true — this config has an on/off toggle
  • enabled_by_default = false — disabled until explicitly turned on per domain

Per-domain toggle

The backoffice toggle writes to config.client_configs.enabled:

-- Enable for one domain
INSERT INTO config.client_configs (domain, config_slug, config_scope, enabled)
VALUES ('acme.com', 'notifications', 'website', true)
ON CONFLICT (domain, config_slug, config_scope) DO UPDATE SET enabled = true;

Feature dependencies

Use the requires column in config.configs to declare dependencies:

INSERT INTO config.configs (slug, ..., requires, ...)
VALUES ('content_gating', ..., ARRAY['agents'], ...);

ConfigResolver walks the requires chain recursively — if any dependency is disabled, the feature returns None.

Access Control

x-access controls who can see and edit fields in the backoffice.

x-access value Who sees it Default?
(omitted) Clients and staff Yes — client-visible by default
"staff" Staff only (Rose admins) No — must be explicit

Schema-level: Applies to the entire config slug.

{
  "$id": "system",
  "x-access": "staff",
  "properties": { ... }
}

Property-level: Overrides the schema-level default for that specific field.

{
  "$id": "traffic_control",
  "properties": {
    "traffic_percentage": { "type": "integer" },
    "spa_widget_handling": { "type": "object", "x-access": "staff" }
  }
}

Here, traffic_percentage is client-visible but spa_widget_handling is staff-only.

Resolution order: property-level x-access > schema-level x-access > "client" (global default).

Config Resolution Flow

The diagram below shows how ConfigResolver resolves a config for a given domain. There are three layers of values, applied in priority order (highest wins):

flowchart TD Start(["resolver.get_config(slug)"]) --> ClientCheck{"client enabled?"} ClientCheck -- No --> ReturnNone(["return None"]) ClientCheck -- Yes --> LookupDef["Lookup ConfigDefinition"] LookupDef --> IsFeature{"is_feature?"} IsFeature -- Yes --> CheckDeps{"dependencies enabled?"} CheckDeps -- No --> ReturnNone CheckDeps -- Yes --> CheckToggle{"feature enabled?"} CheckToggle --> ToggleLogic["Check toggle priority: 1. client_configs.enabled 2. configs.enabled_by_default Highest non-null wins"] ToggleLogic --> Enabled{"enabled?"} Enabled -- No --> ReturnNone Enabled -- Yes --> MergeStep IsFeature -- No --> MergeStep MergeStep["Merge config values"] --> MergeLogic subgraph MergeLogic ["Value Resolution Priority"] direction TB L1["Priority 1 — Client override client_configs.config JSONB Per-domain values set in backoffice"] L2["Priority 2 — Global default configs.default_config JSONB Platform-wide defaults in DB"] L3["Priority 3 — Schema default Pydantic model defaults Fills any remaining gaps"] L1 --> L2 --> L3 end MergeLogic --> Strategy{"merge_strategy?"} Strategy -- deep_merge --> DeepMerge["Recursively overlay: client on top of global default then fill gaps with schema defaults"] Strategy -- full_override --> FullOverride["Replace entirely: use client override as-is if present otherwise use global default"] DeepMerge --> Validate["Pydantic model_validate"] FullOverride --> Validate Validate --> ReturnConfig(["return typed config"]) style MergeLogic fill:#f0f4ff,stroke:#4a6fa5 style L1 fill:#fff3cd,stroke:#d4a017 style L2 fill:#d4edda,stroke:#28a745 style L3 fill:#e2e3e5,stroke:#6c757d

Priority summary

Priority Source Where it lives When it applies
1 (highest) Client override config.client_configs.config Per-domain values set via backoffice or SQL
2 Global default config.configs.default_config Platform-wide defaults, changeable via SQL without deploy
3 (lowest) Schema default Pydantic model defaults (from schema "default" key) Fills any fields missing from both layers above

With deep_merge (most configs): all three layers are recursively merged — client override wins on any field it sets, global default fills the rest, schema default catches anything still missing.

With full_override (e.g., ctas): if a client override exists, it replaces the global default entirely (no merging). Schema defaults still fill missing Pydantic fields.

Merge Strategies

Strategy Behavior Use case
deep_merge Recursively merge override into defaults; override wins on leaf conflicts Most configs (default)
full_override Replace defaults entirely with override CTAs (list replacement)

Set in the config.configs.merge_strategy column. Default is deep_merge.

Example of deep_merge:

schema default:  {"a": 1, "nested": {"x": 10, "y": 20, "z": 30}}
global default:  {"a": 1, "nested": {"x": 10, "y": 20}}          ← z missing, schema fills it
client override: {"nested": {"x": 99}}                            ← only overrides x
resolved:        {"a": 1, "nested": {"x": 99, "y": 20, "z": 30}} ← all three layers merged

Operational Changes (No Deploy Needed)

These changes take effect after the ConfigResolver cache expires (60s for client configs, 300s for definitions).

Toggle a feature globally

UPDATE config.configs SET enabled_by_default = true WHERE slug = 'content_gating';

Toggle a feature for one domain

INSERT INTO config.client_configs (domain, config_slug, config_scope, enabled)
VALUES ('acme.com', 'content_gating', 'website', true)
ON CONFLICT (domain, config_slug, config_scope) DO UPDATE SET enabled = true;

Change a global default value

UPDATE config.configs
SET default_config = jsonb_set(default_config, '{dynamic_questions,number_of_questions}', '5')
WHERE slug = 'engagement';

Override a value for one domain

INSERT INTO config.client_configs (domain, config_slug, config_scope, config)
VALUES ('acme.com', 'engagement', 'website', '{"dynamic_questions": {"number_of_questions": 5}}')
ON CONFLICT (domain, config_slug, config_scope)
DO UPDATE SET config = EXCLUDED.config;

Quick Reference: Commands

Command Where What it does
just validate schemas/ Validates all schema files (JSON syntax + required fields)
just codegen schemas/ Generates Pydantic + TypeScript from schemas
just list schemas/ Lists all schemas and their properties
just check-slugs schemas/ Cross-checks schema files against DB seed migration
just mypy packages/ixconfig backend/ Type-checks the ixconfig package