---
title: "Admin Settings Scopes and Resolution"
slug: admin-settings-scopes
section: Governance & Operations
status: stable
description: "Tiered application settings in the Hub admin API: which override rows exist for a key, and which row wins for a given brand, program and season."
last-updated: 2026-10-07
---

# Admin Settings Scopes and Resolution

Tiered application settings in the Hub admin API: which override rows exist for a key, and which row wins for a given brand, program and season.

## Overview

The Hub (BackOfficePrototype) stores application settings in the `app_settings` table, one row per key per scope tier. The routes live in `BackOfficePrototype server/routes/adminSettings.ts`; the pure resolver is in `server/services/scopedSettings.ts`; the in-memory cache that other server code reads is `server/services/appSettingsCache.ts`. The admin UI is `client/src/pages/admin-settings.tsx`. The same file also serves the feature-flag routes (`/api/admin/feature-flags`), which use the same tiers but are not covered in detail here.

Permission keys and roles are described in [Roles & Governance](/docs/rbac); the season concept is described in [Seasons](/docs/seasons).

## Scope Tiers and Permissions

`SCOPE_TYPES` in `shared/models/system.ts` is `global`, `brand`, `program`, `season`. Write access is gated by the tier in the request:

| Tier | Write permission | Needs `scopeId` |
| --- | --- | --- |
| global | `settings.global.manage` | No (on PUT, must be absent or null) |
| brand | `settings.brand.manage`, plus a brand-scope check on `scopeId` | Yes |
| program | `settings.program.manage` | Yes |
| season | `settings.season.manage` | Yes |

Reads (list, scopes, resolve, audit-history) accept any one of the four `settings.*.manage` permissions. A user holding `system.admin` passes `requirePermission` and `requireAnyPermission` (god mode). For brand-tier writes, `requireBrandScope` (`server/rbac.ts`) then requires the brand in `scopeId` to be inside the actor's brand scope unless the actor has all-brand scope or god mode; a scoped user who sends no `scopeId` is rejected with 403.

Only the brand-scope check is applied for brand writes. No equivalent check on `scopeId` is applied for program or season tiers in this file.

## Routes

All routes use the `isAuthenticated` guard, which accepts a signed-in session only. A request that presents an `x-api-key` header without a session gets 401 `SESSION_REQUIRED`, so API keys cannot call these routes even though `requirePermission` contains API-key branches. A session that still has MFA enrolment pending gets 403 `MFA_SETUP_REQUIRED`. Success responses are wrapped as `success: true` plus `data`.

### Read Routes

| Verb and path | Permission | Response `data` |
| --- | --- | --- |
| `GET /api/admin/settings` | any of the four `settings.*.manage` | All `app_settings` rows; optional `?category=` filter |
| `GET /api/admin/settings/:key/scopes` | any of the four | Array of every row for the key (all tiers, including inactive rows). Empty array if the key does not exist |
| `GET /api/admin/settings/:key/resolve` | any of the four | `{ resolved, rows }`: the winning row or null, and all rows for the key |
| `GET /api/admin/settings/audit-history` | any of the four | Audit log rows with `entityType` equal to `app_setting`, newest first, `?limit=` default 50, max 200 |

`/scopes` lists the override rows that currently exist for the key. It does not list which tiers a setting is allowed at; any existing key can take an override at any of the four tiers.

`/resolve` reads three optional query parameters: `brandId`, `programId`, `seasonId`. Empty or missing values count as not supplied.

### Write Routes

| Verb and path | Permission | Body | Result |
| --- | --- | --- | --- |
| `POST /api/admin/settings` | `settings.global.manage` | `key`, `value`, `label`, optional `category` (default `general`), `dataType` (default `boolean`), `description` | 201 with the new global row |
| `PATCH /api/admin/settings/:key` | `settings.global.manage` | any of `value`, `label`, `description`, `isActive` | 200 with the updated global row |
| `PUT /api/admin/settings/:key/scope` | tier permission from body `scopeType` (default global) | `scopeType`, `scopeId`, `value` | 200 with the created or updated row |
| `DELETE /api/admin/settings/:key` | tier permission from body `scopeType` (default global) | `scopeType`, `scopeId` | 200 with `success`, `deleted` (key), `scopeType`, `scopeId` |

`PATCH` only touches the global row. `DELETE` carries its tier in the request body, not the query string. The tier gate runs before the handler for `PUT` and `DELETE`, reading `scopeType` from the body (missing means global). The maintenance-mode write gate (mounted on `/api`) exempts paths starting with `/admin/settings`, so these write routes stay usable while `maintenance_mode` is on. Each write calls `bustAppSettingsCache` and is wrapped in `auditMutation` with actions `app_setting.create`, `app_setting.update`, `app_setting.scope.upsert`, `app_setting.delete`.

## Resolution Order

`resolveScopedSetting` walks tiers in the order season, program, brand, global. For each tier it takes the first row of that tier that matches the context: the global row always matches; a tiered row matches only when its `scopeId` equals the context id for that tier (`seasonId`, `programId`, `brandId`). The first match is returned, so a matching season row beats a program row, which beats a brand row, which beats global. If nothing matches, including no global row, `resolved` is null.

Program and brand are not nested: a program row is chosen from `programId` alone, without checking that the program belongs to the supplied brand.

## Data Model

Table `app_settings` (`shared/models/system.ts`, migration `20260728000000_scoped_settings_and_feature_flags.sql`):

| Column | Notes |
| --- | --- |
| `id` | varchar primary key, `gen_random_uuid()` |
| `key` | text, not null |
| `scope_type` | text, default `global` |
| `scope_id` | varchar, null for global |
| `value` | jsonb, not null |
| `category`, `data_type`, `label`, `description` | metadata; `data_type` default `boolean` |
| `is_active` | boolean, default true |
| `updated_by`, `created_at`, `updated_at` | audit columns |

Uniqueness is the index `idx_app_settings_scope_unique` on `(key, scope_type, COALESCE(scope_id, ''))`, plus a partial unique index `idx_app_settings_global_unique` on `(key, scope_type)` where `scope_id IS NULL`. The migration drops the older single-column unique on `key`. Feature flags use a separate `feature_flags` table.

## Validation and Error Codes

Errors use the envelope `error.code`, `error.message`, `error.details`, `error.traceId` where the handler or middleware fills them.

| Status | `error.code` | Cause |
| --- | --- | --- |
| 401 | `UNAUTHORIZED` | No session (message `Unauthorized`) |
| 401 | `SESSION_REQUIRED` | `x-api-key` header sent without a session |
| 403 | `FORBIDDEN` | Missing permission (message `Permission denied`), or brand outside scope |
| 403 | `MFA_SETUP_REQUIRED` | Session has mandatory MFA enrolment pending |
| 400 | `VALIDATION_ERROR` | Invalid `scopeType` on `PUT` or `DELETE` (top-level code, raised by the tier gate; message `Invalid scopeType: <value>`) |
| 400 | `VALIDATION_ERROR` | Zod failure; message `Request validation failed`, `details.issues` lists problems (from the global error handler) |
| 400 | `VALIDATION_ERROR` | Value type mismatch against `dataType` (`boolean`, `number`, `string`; `json` is not checked). Applies to `POST`, `PATCH` and `PUT` |
| 404 | `NOT_FOUND` | `PATCH` or `DELETE` target row missing (message `Setting not found`); `PUT` when the global row is not defined yet (message `Define the global setting before adding scoped overrides`) |

Zod rules: on `POST`, `key` must match `^[a-z][a-z0-9_]*$` and `label` must be non-empty, and `dataType` is one of `boolean`, `string`, `number`, `json`. On `PUT`, `scopeType` must be one of the four tiers, `scopeId` is required for non-global tiers and rejected for global. `DELETE` has no Zod schema: a non-global `DELETE` without `scopeId` looks up a row with a null `scopeId` and normally returns 404. A duplicate create is not checked in the handler; behaviour then depends on the unique index.

## Behaviour and Edge Cases

- A scoped override copies `category`, `dataType`, `label`, `description` from the global row when it is first created, and its value is validated against the global `dataType`. A `PUT` on an existing override row updates only `value`, `updatedBy` and `updatedAt`.
- Deleting the global row does not remove scoped rows; those rows would then have no fallback.
- The runtime cache loads only rows with `is_active` true and refreshes after 30 seconds (`CACHE_TTL_MS`) or on the next read after a write calls `bustAppSettingsCache`; the cache is in process memory. `/resolve` and `/scopes` read the table directly and do not filter by `is_active`.
- `PATCH` can set `isActive` only on the global row.

## Known Gaps

- The `mutationRegistry.ts` entries for `POST`, `PATCH` and `DELETE /api/admin/settings` list permission `system_settings.manage` and audit actions `setting.*`, which differ from the `settings.*.manage` keys and `app_setting.*` actions the handlers use; a search of that file found no entry for `PUT /api/admin/settings/:key/scope`.
- What `POST` does when `value` is omitted for a `json` setting, or when the key already exists, was not traced beyond the handler code.
- `PATCH` on a non-existent key returns 404 only after the audit middleware has resolved the previous value; how that interacts with the audit log was not traced.
- Whether `programId` or `seasonId` values are validated against real programs or seasons was not found in these handlers.
