Settings
- The split: global, per-app, and defaults
- Settings (global)
- AppSettings
- Resolution rules
- Why the write shape and the read shape differ
The split: global, per-app, and defaults
Settings is configuration, not identity — see Profiles for the identity-data counterpart to this same global/per-app split. Three layers, read in this order:
AppSettings override (this user, this app)
→ Settings override (this user, global)
→ Application's declared default for that key
The hub resolves this fallthrough server-side. See Workflows → Settings resolution, in practice.
| Field | Global Settings |
AppSettings (per User × Application) |
|---|---|---|
locale, timezone, theme |
✓ | Inherits from global unless set |
| notification preferences | ✓ (site-wide channel defaults) | ✓ (per-app channel overrides) |
| app-specific preferences | — | ✓ arbitrary JSON, validated against that app’s settings_schema |
Settings (global)
| Field | Type | Notes |
|---|---|---|
user_id |
string | One per User. |
locale |
string | |
timezone |
string | IANA zone, e.g. America/Denver. |
theme |
enum | light | dark | system. |
notifications |
object | Site-wide channel defaults, e.g. {"email": true, "sms": false}. |
updated_at |
timestamp |
Example
{
"user_id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
"locale": "en-US",
"timezone": "America/Denver",
"theme": "dark",
"notifications": { "email": true, "sms": false },
"updated_at": "2026-08-11T10:00:00Z"
}
AppSettings
| Field | Type | Notes |
|---|---|---|
user_id |
string | |
application_id |
string | |
overrides |
object | Only the keys this user has explicitly overridden for this app — not a full merged object. The API’s read endpoint returns the resolved merge; this is the write-layer shape. |
updated_at |
timestamp |
Schema validation
Validated on write against Application.settings_schema (see Applications). The schema is on file with the hub: each Application registers its own JSON Schema, and the hub validates every write against it rather than storing an opaque blob. Centralized validation is why GET /v1/users/{id}/apps/{appId}/settings can promise a resolved, valid object instead of whatever was last written. The same schema also tells the storage layer which declared fields to back with typed, indexed columns; see Database Schema → Typed fields.
Example
Write (only the override):
{ "overrides": { "week_start": "monday" } }
Read, resolved (GET /v1/users/{id}/apps/{appId}/settings):
{
"application_id": "app_timetrack",
"resolved": {
"locale": "en-US",
"theme": "dark",
"default_billable": true,
"week_start": "monday"
},
"overrides": { "week_start": "monday" }
}
Here, locale and theme fell through to global Settings, default_billable fell through to the Application’s declared default, and only week_start reflects an explicit per-app override.
Resolution rules
The exact algorithm behind GET /v1/users/{id}/apps/{appId}/settings. Implementations and tests should follow it literally.
There are two disjoint key spaces:
- Reserved global keys:
locale,timezone,theme,notifications. These are defined by the platform. An Application’ssettings_schemamay not declare them, and declaring one returns422 reserved_settings_keyon the Application write. - Declared keys: every property in the Application’s
settings_schema.
for key in reserved_global_keys:
if key == "notifications":
resolved.notifications = merge_one_level(global.notifications, valid_override("notifications") or {})
else:
resolved[key] = valid_override(key) ?? global[key]
for key, property in settings_schema.properties:
if valid_override(key) exists: resolved[key] = override
elif "default" in property: resolved[key] = property.default
else: omit key
valid_override(key) := overrides[key] if it validates against the CURRENT schema, else
treat as absent and list key in stale_overrides
The Application’s declared default is the JSON Schema default keyword on each property, so there’s no separate defaults field to keep in sync. A user can override a reserved global key per app. For example, theme: "light" in one app while their global theme stays dark.
settings_schema rules
The rules an Application’s schema must follow. They’re checked on POST/PATCH /v1/applications, and a violation returns 422 invalid_settings_schema with per-path details.
- JSON Schema draft 2020-12. The root must be
{"type": "object", "properties": {...}}. The server always treatsadditionalPropertiesasfalse, whatever the schema says. - At most 200 properties, at most 64 KB serialized, and property names must match
^[a-z][a-z0-9_]{0,62}$. - Supported property types:
string(optionallyenum,format: "date-time",maxLength),boolean,integer,number(with optionalminimum/maximum), andobject/array. Objects and arrays are stored, but never get a typed projection; see Database Schema → Typed fields. $refis supported only within the document, and remote$refis rejected.- A
default, if present, must itself validate against its property. x-pii: trueon a property marks it as personal data. The hard-delete cascade removes PII-marked keys from a deleted user’s overrides and leaves the rest, such as aweek_startpreference. A schema withoutx-piimarks is treated as having no PII.- Changing a schema never rewrites stored data. Removing a property, or changing its type, makes existing overrides for it stale (see
stale_overridesabove). It doesn’t make them errors. The Application’s owner can see how many users hold stale values per key inGET /v1/applications/{id}→settings_schema_stats.
Why the write shape and the read shape differ
Writing only the override keeps a clean record of what the user actually chose versus what they’re merely inheriting — necessary so that if the Application changes its default for default_billable tomorrow, every user who never overrode it picks up the new default automatically, while anyone who explicitly set it keeps their choice. Returning the fully resolved object on read means callers never re-implement the three-layer fallthrough themselves.