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¶
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¶
This regenerates all 6 output files (3 Python, 3 TypeScript). The new property appears as a typed field on AppearanceConfig.
3. Verify types¶
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¶
3. Run 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¶
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_configvalue should match the"default"key in the schema file.
5. Verify slugs match¶
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:
- Database seed: The migration copies these values into
config.configs.default_config. - Codegen fallback: ConfigResolver uses schema defaults to fill missing fields when resolving.
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:
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 toggleenabled_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.
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):
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¶
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 |