Source: ddx-documentation/adr/0014-unified-settings.md
- **Status:** Accepted (2026-10-23, owner answer: settings are per role and cascade platform → org → user). Open item: the `public` persona route for org-admin and nurse is NEEDS_REVIEW (see Decision). - **Refines:** [ADR 0003](/en/docs/adr/0003-route-shape), [ADR 0013](/en/docs/adr/0013-super-admin-platform-role) - **Code:** `packages/ddx-contracts/src/settings.ts`, `ddx-api/src/settings/**`, `ddx-api/prisma/main/schema.prisma` (`UserSetting`), `ddx-web/src/services/settings.ts`, `ddx-web/src/app/[locale]/(app)/portal/[orgSlug]/[role]/settings/**` ## Context - A patient got `403 Missing scope(s): settings:read` on every portal page. The only settings read was the org-admin route `GET /orgs/:orgSlug/settings`, guarded by the coarse scope `settings:read`. - The store was org-only (`org_setting`, 4 keys). ddx-web kept two settings systems and merged layers itself (`mergeSettings`, `SYSTEM_SETTINGS`). ## Decision - **One registry** `SETTING_REGISTRY` in `@ddx/contracts/settings`. Each key has a value schema, a default, a category, an org tier `{read, edit}` role set and an optional user tier `{read, edit}` role set. Roles are `ORG_ROLES`. A platform super-admin arrives as `org-admin` (ADR 0013). - **Three tiers.** Platform = the code defaults (`SETTING_DEFAULTS`, no table). Org = `org_setting`. User = new table `user_setting` (`orgId`, `userId`, `key`, `value`, `version`; unique `orgId+userId+key`; `userId` is a plain string, no FK). `org_setting` is unchanged. - **Resolve once, in ddx-api.** Cascade: user (if the key has a user tier) → org → platform default. The pure resolver `ddx-api/src/settings/settings.resolver.ts` filters keys by caller role and returns per key `value`, `source` (`user|org|platform`), the `org` and `user` tier blocks (only when the caller may read that tier) and `editable.org` / `editable.user`. ddx-web renders the answer; it does not merge. - **API mirror, type-locked.** ddx-api keeps a runtime mirror `SETTING_REGISTRY_MIRROR` declared `as const satisfies SettingRegistry`. Drift from the contract fails typecheck. The contract stays type-only for the CJS ddx-api. - **Routes** (all `/orgs/:orgSlug/settings…`): - `GET settings/effective` — any org member, `jwt` or `user-key`, no `@RequireScopes`, no `@Roles`. Answers 200 with the role-filtered list. - `PATCH settings/me/:key` and `DELETE settings/me/:key` — own user only (`@CurrentPrincipal()`; no `:userId` route). 400 when the key has no user tier, 403 when the role may not edit it, 409 on a stale `version`. Reset is idempotent. - `GET settings` and `PATCH settings/:key` — org tier; keep `settings:read` / `settings:update` and `@Roles('org-admin')` on the write. The service also checks the registry org-edit set (service keys exempt). - **Audit and events.** Every org write, user write and user reset calls `AuditService.record` (`org-setting.update`, `user-setting.update`, `user-setting.reset`). Writes publish SSE `settings.changed`; the user-tier event carries `userId`. - **Density stays a browser cookie** ([ADR 0012](/en/docs/adr/0012-density-names)); it is not a setting. - **Web.** One read `GET settings/effective` through the BFF (`getEffectiveSettings`, `useSettings`, `useSetting`). One `SettingsService` for `updateOrg` / `updateUser` / `resetUser`. The settings page renders from the registry-driven `SETTING_FIELDS` map in two sections, personal and organization, each with a source hint. - **NEEDS_REVIEW:** no org-admin portal persona exists, so org-admin and nurse members (mapped to the `public` persona) reach settings through a `SETTINGS` entry in the `public` nav (`ddx-web/src/lib/navigation/portal-registry.ts`). The owner decides whether a real org-admin persona replaces this. ## Registry v1 | Key | Default | Org read / edit | User read / edit | |---|---|---|---| | `locale.default` | `'en'` | all roles / org-admin | all / all | | `locale.enabled` | `['en','fr','de','es']` | all / org-admin | none | | `features.ai` | `false` | org-admin, doctor, nurse, receptionist / org-admin | none | | `notifications.digest` | `{enabled:false,hour:8}` | all / org-admin | all / all | | `display.timeZone` | `'Europe/Berlin'` | all / org-admin | all / all | A patient sees 4 keys (no `features.ai`) and edits 3 user-tier keys. ## Consequences - Adding a key = one entry in the contract registry (+ schema + default) + the API mirror + a `SETTING_FIELDS` entry in the web settings model + label and description keys in all four `messages/*/portal.json`. - The patient `403` is gone: `effective` has no scope requirement and filters by role in the service. - The seed adds `display.timeZone` per org and two user rows (patient `locale.default='de'`, doctor `notifications.digest`). - Not in scope: per-team or per-project tiers, import/export, a stored platform override table, a patient `settings:read` grant. - Judge: resolver spec `ddx-api/src/settings/settings-resolve.spec.ts` (21 tests pass) and a curl + browser pass for org-admin, doctor and patient with 0 `settings:read` 403 lines. ## Addendum 2026-10-25 — harness-alignment (supersedes the points named; history above stays) - **Platform tier is stored and editable.** Model `PlatformSetting` (`key` unique, `value`, `version`) in `ddx_main`. Routes `GET /platform/settings` and `PATCH /platform/settings/:key` (body `{value, version}`, `version` required; 409 on a stale version). Platform `super-admin` only; every write is audited (ProofChain stream `platform-settings`). Defaults seed the rows create-only. This replaces "Platform = the code defaults, no table" and the "not in scope: a stored platform override table" line. - **Scoped keys.** Keys are `<scope>.<group>.<key>` with scopes `platform`, `site`, `portal`, `module.<moduleId>` ([naming-constitution.md](/en/docs/naming-constitution) §6). The v1 keys map: `locale.default` → `portal.locale.default`, `locale.enabled` → `portal.locale.enabled`, `features.ai` → `portal.features.ai`, `notifications.digest` → `portal.notifications.digest`, `display.timeZone` → `portal.display.timeZone`. New keys: `portal.display.themeMode`, `portal.display.density`, `portal.display.motion`, `module.demo-entities.viewMode`. Stored rows were renamed by migration. - **AI switch.** Owner decision 2026-10-25: each org chooses, so the key lives in the `portal` scope (`portal.features.ai`), not `platform`. - **Density vocabulary.** The canonical values are `compact | cosy | large` (`ddx-web/src/lib/theme/density.ts`; `comfy` and `cozy` are legacy input aliases). Density is now the `portal.display.density` setting served by the facade; the cookie only caches the facade value ([ADR 0012](/en/docs/adr/0012-density-names)). This supersedes "Density stays a browser cookie". - **Web facade.** `UnifiedSettingsProvider` and `useUnifiedSetting(key)` replace `SettingsProvider`; one query per org. - **Events.** A platform write publishes `settings.changed` to every org in pages of 100, carrying key and version only. - **Roles.** The role names are `staff` (ex doctor and nurse), `member` (ex receptionist), `client` (ex patient). The nurse fold widens rights for ex-nurses (progress/015.md M1, owner decision). The NEEDS_REVIEW item now concerns `org-admin` only. - Guide: [settings.md](/en/docs/settings). --- Last reconciled: 2026-10-25 · plan harness-alignment task 038
public persona route for org-admin and nurse is NEEDS_REVIEW (see Decision).packages/ddx-contracts/src/settings.ts, ddx-api/src/settings/**, ddx-api/prisma/main/schema.prisma (UserSetting), ddx-web/src/services/settings.ts, ddx-web/src/app/[locale]/(app)/portal/[orgSlug]/[role]/settings/**403 Missing scope(s): settings:read on every portal page. The only settings read was the org-admin route GET /orgs/:orgSlug/settings, guarded by the coarse scope settings:read.org_setting, 4 keys). ddx-web kept two settings systems and merged layers itself (mergeSettings, SYSTEM_SETTINGS).SETTING_REGISTRY in @ddx/contracts/settings. Each key has a value schema, a default, a category, an org tier {read, edit} role set and an optional user tier {read, edit} role set. Roles are ORG_ROLES. A platform super-admin arrives as org-admin (ADR 0013).SETTING_DEFAULTS, no table). Org = org_setting. User = new table user_setting (orgId, userId, key, value, version; unique orgId+userId+key; userId is a plain string, no FK). org_setting is unchanged.ddx-api/src/settings/settings.resolver.ts filters keys by caller role and returns per key value, source (user|org|platform), the org and user tier blocks (only when the caller may read that tier) and editable.org / editable.user. ddx-web renders the answer; it does not merge.SETTING_REGISTRY_MIRROR declared as const satisfies SettingRegistry. Drift from the contract fails typecheck. The contract stays type-only for the CJS ddx-api./orgs/:orgSlug/settings…):
GET settings/effective — any org member, jwt or user-key, no @RequireScopes, no @Roles. Answers 200 with the role-filtered list.PATCH settings/me/:key and DELETE settings/me/:key — own user only (@CurrentPrincipal(); no :userId route). 400 when the key has no user tier, 403 when the role may not edit it, 409 on a stale version. Reset is idempotent.GET settings and PATCH settings/:key — org tier; keep settings:read / settings:update and @Roles('org-admin') on the write. The service also checks the registry org-edit set (service keys exempt).AuditService.record (org-setting.update, user-setting.update, user-setting.reset). Writes publish SSE settings.changed; the user-tier event carries userId.GET settings/effective through the BFF (getEffectiveSettings, useSettings, useSetting). One SettingsService for updateOrg / updateUser / resetUser. The settings page renders from the registry-driven SETTING_FIELDS map in two sections, personal and organization, each with a source hint.public persona) reach settings through a SETTINGS entry in the public nav (ddx-web/src/lib/navigation/portal-registry.ts). The owner decides whether a real org-admin persona replaces this.A patient sees 4 keys (no features.ai) and edits 3 user-tier keys.
SETTING_FIELDS entry in the web settings model + label and description keys in all four messages/*/portal.json.403 is gone: effective has no scope requirement and filters by role in the service.display.timeZone per org and two user rows (patient locale.default='de', doctor notifications.digest).settings:read grant.ddx-api/src/settings/settings-resolve.spec.ts (21 tests pass) and a curl + browser pass for org-admin, doctor and patient with 0 settings:read 403 lines.PlatformSetting (key unique, value, version) in ddx_main. Routes GET /platform/settings and PATCH /platform/settings/:key (body {value, version}, version required; 409 on a stale version). Platform super-admin only; every write is audited (ProofChain stream platform-settings). Defaults seed the rows create-only. This replaces "Platform = the code defaults, no table" and the "not in scope: a stored platform override table" line.<scope>.<group>.<key> with scopes platform, site, portal, module.<moduleId> ( §6). The v1 keys map: locale.default → portal.locale.default, locale.enabled → portal.locale.enabled, features.ai → portal.features.ai, notifications.digest → portal.notifications.digest, display.timeZone → portal.display.timeZone. New keys: portal.display.themeMode, portal.display.density, portal.display.motion, module.demo-entities.viewMode. Stored rows were renamed by migration.portal scope (portal.features.ai), not platform.compact | cosy | large (ddx-web/src/lib/theme/density.ts; comfy and cozy are legacy input aliases). Density is now the portal.display.density setting served by the facade; the cookie only caches the facade value (). This supersedes "Density stays a browser cookie".UnifiedSettingsProvider and useUnifiedSetting(key) replace SettingsProvider; one query per org.settings.changed to every org in pages of 100, carrying key and version only.staff (ex doctor and nurse), member (ex receptionist), client (ex patient). The nurse fold widens rights for ex-nurses (progress/015.md M1, owner decision). The NEEDS_REVIEW item now concerns org-admin only.Last reconciled: 2026-10-25 · plan harness-alignment task 038
adr/
deploy/
research/