Source: ddx-documentation/settings.md
One registry, three stored tiers, resolved once in ddx-api. Decision record: [ADR 0014](/en/docs/adr/0014-unified-settings). Task router row: `CLAUDE_TASK_FLOWS.md` row 4. ## Where settings live | Layer | Path | Role | |---|---|---| | Registry | `packages/ddx-contracts/src/settings.ts` | `SETTING_SCHEMAS` (Zod), `SETTING_DEFAULTS`, `SETTING_REGISTRY` (category + tier ACL) | | API mirror | `ddx-api/src/settings/settings.schemas.ts` | `satisfies`-locked to the registry: a drift fails typecheck | | Resolver | `ddx-api/src/settings/settings.resolver.ts`, `settings.service.ts` | merges the tiers; the only merge in the stack | | Org / user routes | `ddx-api/src/settings/settings.controller.ts` | `/orgs/:orgSlug/settings` (`/effective`, `/me/:key`) | | Platform routes | `ddx-api/src/settings/platform-settings.controller.ts` | GET `/platform/settings`, PATCH `/platform/settings/:key` | | Public branding read | `ddx-api/src/settings/platform-branding.controller.ts` | GET `/platform/branding` (anonymous, platform tier only; see Branding keys) | | Web facade | `ddx-web/src/components/providers/SettingsProvider.tsx` | `UnifiedSettingsProvider` (one query per org) | | Web hook | `ddx-web/src/lib/settings/use-unified-setting.ts` | `useUnifiedSetting(key)` → `{ setting, value, editable, pending, update }` | | Labels | `ddx-web/messages/{en,fr,de,es}/settings.json` | `settings.registry.keys.*`, `settings.registry.options.*` | | Seed | `ddx-api/prisma/seed/seed-main.ts` | `seedPlatformSettings()` writes one platform row per key from `SETTING_DEFAULTS` | ## Key shape `<scope>.<group>.<key>`. Scopes: `platform`, `site`, `portal`, `module.<moduleId>`. The group is one of `SETTING_CATEGORIES` (`locale`, `features`, `notifications`, `display`, `branding`). A `module.<moduleId>.<key>` key has no group segment; its registry entry declares the category. Examples: `portal.display.themeMode`, `portal.display.density` (`compact` | `cosy` | `large`, rows only), `portal.display.textSize` (`sm` | `md` | `lg`, type only), `portal.features.ai`, `portal.branding.palette`, `module.demo-entities.viewMode`. ## Tier and resolve model Resolve order, lowest first: **default → platform → org → user**. 1. **Default** — `SETTING_DEFAULTS`. It is the seed of the platform tier (version 0), not a runtime source. 2. **Platform** — one `PlatformSetting` row per key. Only a platform super-admin edits it, only through GET / PATCH `/platform/settings[/:key]`. PATCH needs the current `version` (optimistic lock). No org role can write this tier. 3. **Org** — the org row. Read / edit roles come from `SETTING_REGISTRY[key].tiers.org`. 4. **User** — only when the key declares `tiers.user`. Read / edit roles come from there. ddx-api resolves the tiers once and serves the effective value. ddx-web never merges tiers, never keeps a second store, and never reads a preference from a cookie or localStorage as the source while the facade is mounted (a cookie may only cache the facade value). On a change, ddx-api emits `settings.changed` on the SSE bus with the **key and version only** — never the value. ddx-web maps it to the query key `['org', orgId, 'settings']` and refetches through the BFF. ## Branding keys Five keys in category `branding` (plan `tenant-branding`, [ADR 0019](/en/docs/adr/0019-tenant-branding)). Each has a platform default and an org override; none has a user tier. Org tier: everyone reads, only `org-admin` edits. Platform tier: only platform `super-admin` edits. | Key | Schema | Default | Org tier | |---|---|---|---| | `portal.branding.displayName` | object `{ en?, fr?, de?, es? }`, each 1-48 chars, strict | `{}` (empty: the messages name `site.brand.name`) | read all · edit `org-admin` | | `portal.branding.palette` | `dudoxx` · `neutral` · `solace-grid` · `verdant-loop` | `dudoxx` | read all · edit `org-admin` | | `portal.branding.font` | `dudoxx` · `editorial` · `serif` | `dudoxx` | read all · edit `org-admin` | | `portal.branding.illustration` | `signal` · `grid` · `orbit` · `none` | `signal` | read all · edit `org-admin` | | `portal.branding.logo` | object `{ mode: platform \| generated \| upload, style: monogram-circle \| monogram-square \| wordmark }` | `{ mode: platform, style: monogram-square }` | read all · edit `org-admin` | Values are ids, never a colour or a font family. A palette id is the `data-brand` value of a vendored brand file; a font id is a `data-font` pair (`dudoxx` = Urbanist, `editorial` = Fraunces, `serif` = Source Serif 4). - **Public read**: `GET /platform/branding` returns `{ data: PlatformBranding }`, the five platform-tier values only (strict projection). The route is anonymous (`@Public()`), and the BFF lists `/platform/branding` in `PUBLIC_PATHS`, so the site and `/auth` read it without a session. ddx-web reads it cookie-less through `callUpstream` (`'use cache'`, tag `platform-branding`); a platform write to a branding key revalidates that tag. - **Editors**: the platform page (`/{locale}/platform/settings`) has a typed editor per key. The org settings page has a branding section with a live preview, and the new-tenant wizard (`<P>/settings/branding`) edits the same keys step by step. - **Portal fallback**: when an org never set `portal.branding.displayName` or `.logo` at org tier, the portal shows the org name and the uploaded org logo, else a mark generated from the org name. It never shows the platform name or logo; those stay on the site scope. - **Theme writer**: `BrandThemeScope` (`ddx-web/src/components/providers/BrandingProvider.tsx`) is the one writer of `<html data-brand|data-font>`. It is a layer stack: site scope, tenant scope, and a `preview` layer for a draft (the wizard). ## Add a setting key 1. **Registry entry** in `packages/ddx-contracts/src/settings.ts`: - the key with its scope segment (`portal.<group>.<key>` or `module.<moduleId>.<key>`); - the Zod schema in `SETTING_SCHEMAS` (enum tuples as an exported `SETTING_*` const); - the default in `SETTING_DEFAULTS`; - the ACL in `SETTING_REGISTRY`: `category`, `tiers.org` (`read` / `edit` roles) and, when users may override it, `tiers.user`. 2. **API mirror** — add the same key to `ddx-api/src/settings/settings.schemas.ts`. The `satisfies` lock fails typecheck (lead-run) when the two drift. 3. **Labels in 4 locales** — `settings.registry.keys.<name>.label` and `.description`, plus `settings.registry.options.<name>.<value>` for each enum value, in `ddx-web/messages/{en,fr,de,es}/settings.json` (`<name>` = the last key segment, e.g. `themeMode`). All four files in the same change. A key shown on the portal settings page also adds its label keys to the typed union in `ddx-web/src/app/[locale]/(app)/portal/[orgSlug]/[role]/settings/_islands/model.ts`. 4. **UI control (optional)** — read and write it with `useUnifiedSetting('<key>')` inside `UnifiedSettingsProvider`. Use `editable` to disable the control and `update(value)` to write; the hook does the optimistic PATCH and the rollback. Compose the control from inventory entries (`@/inventory/<family>`), never a raw UI import in a page. 5. **Seed** — nothing to write. `seedPlatformSettings()` iterates `SETTING_KEYS` and creates the missing platform row from `SETTING_DEFAULTS` (create-only; a re-seed never overwrites an operator edit). No migration: the tiers are generic rows, never a per-key table. 6. **Gates** — `./ddx-manage.sh i18n` (labels) and `./ddx-manage.sh kernel` (regression guard) exit 0. Lint and typecheck run once by the lead at `/unify`. ## Never - A second settings store, a per-key table or a tier merge in ddx-web. - A setting value in an SSE payload. - A platform-tier write from any role but platform super-admin. - A label outside `ddx-web/messages/{en,fr,de,es}/`. --- Last reconciled: 2026-10-25 · plan tenant-branding task 010
One registry, three stored tiers, resolved once in ddx-api. Decision record:
. Task router row: CLAUDE_TASK_FLOWS.md row 4.
<scope>.<group>.<key>. Scopes: platform, site, portal, module.<moduleId>.
The group is one of SETTING_CATEGORIES (locale, features, notifications, display, branding).
A module.<moduleId>.<key> key has no group segment; its registry entry declares the category.
Examples: portal.display.themeMode, portal.display.density (compact | cosy | large, rows only),
portal.display.textSize (sm | md | lg, type only),
portal.features.ai, portal.branding.palette, module.demo-entities.viewMode.
Resolve order, lowest first: default → platform → org → user.
SETTING_DEFAULTS. It is the seed of the platform tier (version 0), not a
runtime source.PlatformSetting row per key. Only a platform super-admin edits it,
only through GET / PATCH /platform/settings[/:key]. PATCH needs the current version
(optimistic lock). No org role can write this tier.SETTING_REGISTRY[key].tiers.org.tiers.user. Read / edit roles come from there.ddx-api resolves the tiers once and serves the effective value. ddx-web never merges tiers, never keeps a second store, and never reads a preference from a cookie or localStorage as the source while the facade is mounted (a cookie may only cache the facade value).
On a change, ddx-api emits settings.changed on the SSE bus with the key and version
only — never the value. ddx-web maps it to the query key ['org', orgId, 'settings']
and refetches through the BFF.
Five keys in category branding (plan tenant-branding, ).
Each has a platform default and an org override; none has a user tier. Org tier: everyone reads,
only org-admin edits. Platform tier: only platform super-admin edits.
Values are ids, never a colour or a font family. A palette id is the data-brand value of a
vendored brand file; a font id is a data-font pair (dudoxx = Urbanist, editorial = Fraunces,
serif = Source Serif 4).
GET /platform/branding returns { data: PlatformBranding }, the five
platform-tier values only (strict projection). The route is anonymous (@Public()), and the BFF
lists /platform/branding in PUBLIC_PATHS, so the site and /auth read it without a session.
ddx-web reads it cookie-less through callUpstream ('use cache', tag platform-branding); a
platform write to a branding key revalidates that tag./{locale}/platform/settings) has a typed editor per key. The org
settings page has a branding section with a live preview, and the new-tenant wizard
(<P>/settings/branding) edits the same keys step by step.portal.branding.displayName or .logo at org tier,
the portal shows the org name and the uploaded org logo, else a mark generated from the org name.
It never shows the platform name or logo; those stay on the site scope.BrandThemeScope (ddx-web/src/components/providers/BrandingProvider.tsx) is
the one writer of <html data-brand|data-font>. It is a layer stack: site scope, tenant scope, and
a preview layer for a draft (the wizard).packages/ddx-contracts/src/settings.ts:
portal.<group>.<key> or module.<moduleId>.<key>);SETTING_SCHEMAS (enum tuples as an exported SETTING_* const);SETTING_DEFAULTS;SETTING_REGISTRY: category, tiers.org (read / edit roles) and,
when users may override it, tiers.user.ddx-api/src/settings/settings.schemas.ts. The
satisfies lock fails typecheck (lead-run) when the two drift.settings.registry.keys.<name>.label and .description, plus
settings.registry.options.<name>.<value> for each enum value, in
ddx-web/messages/{en,fr,de,es}/settings.json (<name> = the last key segment, e.g.
themeMode). All four files in the same change. A key shown on the portal settings page
also adds its label keys to the typed union in
ddx-web/src/app/[locale]/(app)/portal/[orgSlug]/[role]/settings/_islands/model.ts.useUnifiedSetting('<key>') inside
UnifiedSettingsProvider. Use editable to disable the control and update(value) to
write; the hook does the optimistic PATCH and the rollback. Compose the control from
inventory entries (@/inventory/<family>), never a raw UI import in a page.seedPlatformSettings() iterates SETTING_KEYS and creates
the missing platform row from SETTING_DEFAULTS (create-only; a re-seed never overwrites
an operator edit). No migration: the tiers are generic rows, never a per-key table../ddx-manage.sh i18n (labels) and ./ddx-manage.sh kernel (regression
guard) exit 0. Lint and typecheck run once by the lead at /unify.ddx-web/messages/{en,fr,de,es}/.Last reconciled: 2026-10-25 · plan tenant-branding task 010
adr/
deploy/
research/