Configuration Resolution
How a setting resolves — the env → global → tenant → user → default cascade, its storage, and the SettingsService API
This page describes the shipped contract. Every symbol below exists in the
repository today: the resolver is SettingsService
(@objectstack/service-settings, ADR-0007), the store is the sys_setting
object, and the authoring surface is the SettingsManifest schema
(generated reference: Settings Manifest).
Anything the platform does not do is listed once, plainly, in
Outside this contract — it is not described as a
roadmap.
ObjectStack resolves a setting through a declared cascade: an OS_*
environment variable, then rows in a single K/V table at global / tenant / user
scope, then the default declared by the plugin that owns the setting. One
resolver serves every namespace, so "where did this value come from" always has
one answer — and the read API returns that answer with the value.
The Configuration Problem
Traditional applications struggle with configuration management:
// Where does apiKey come from? 🤷
const apiKey =
process.env.API_KEY || // Environment variable?
config.stripe.apiKey || // Config file?
tenantSettings.apiKey || // Database?
userPrefs.apiKey || // User override?
'fallback-key'; // Hardcoded default?Each source is read by a different line of code, the precedence is whatever the
|| chain happens to spell, and nothing can tell an operator which source won.
ObjectStack replaces the chain with one resolver and one declared order.
Resolution Order
SettingsService.get() walks exactly five layers, highest first:
┌─────────────────────────────────────────────────────────────┐
│ 1. ENV process.env.OS_<NAMESPACE>_<KEY> │
│ Present ⇒ wins AND locks (source='env') │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 2. GLOBAL sys_setting WHERE scope='global' │
│ Platform-wide row (user_id = null) │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 3. TENANT sys_setting WHERE scope='tenant' │
│ Scoped to the caller's organization │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 4. USER sys_setting WHERE scope='user' │
│ Pinned to ctx.userId │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 5. DEFAULT the specifier's `default` in the manifest │
└─────────────────────────────────────────────────────────────┘Two rules follow from the walk, and both are enforced in SettingsService:
- First non-null entry wins. Values are not merged — no deep merge, no array concatenation. A row at a higher scope replaces the layer below it wholesale.
- A lock anywhere up the chain locks the effective value. An
OS_*override always locks; a row may additionally carrylocked=true, and then a write against any lower scope is rejected withSETTINGS_LOCKED.
How far down the cascade a key is even eligible to travel is declared, not chosen by the caller — see Scope is declared.
Declaring a Setting
A plugin does not create a table. It registers a SettingsManifest, and every
value in it persists in the shared sys_setting store.
import type { SettingsManifest } from '@objectstack/spec/system';
export const crmSettingsManifest: SettingsManifest = {
namespace: 'crm',
version: 1,
label: 'CRM',
description: 'Account and scoring options for the CRM package.',
// Default scope for every specifier below; individual keys may narrow it.
scope: 'tenant',
readPermission: 'setup.access',
writePermission: 'setup.write',
specifiers: [
{
type: 'number',
key: 'max_accounts_per_user',
label: 'Max accounts per user',
required: false,
default: 1000,
},
{
type: 'toggle',
key: 'enable_scoring',
label: 'Enable scoring',
required: false,
default: true,
},
{
type: 'select',
key: 'sync_interval',
label: 'Sync interval',
required: false,
default: 'daily',
options: [
{ label: 'Hourly', value: 'hourly' },
{ label: 'Daily', value: 'daily' },
{ label: 'Weekly', value: 'weekly' },
],
},
{
type: 'password',
key: 'api_key',
label: 'API key',
required: true,
// `password` implies encrypted: true — the value never lands in
// sys_setting.value, only a handle into sys_secret.
},
],
};Register it once at boot:
const settings = ctx.getService('settings');
settings.registerManifest(crmSettingsManifest);Keys are snake_case storage paths inside the namespace — (namespace, key),
not a dotted global path. The full specifier vocabulary (19 types, visibility
expressions, action buttons, per-key permissions) is documented in the
Settings Manifest reference.
Reading Configuration
const settings = ctx.getService('settings');
// Resolve one key. Returns the value AND where it came from.
const resolved = await settings.get('crm', 'max_accounts_per_user', {
userId: session.userId,
});
// {
// value: 1000,
// source: 'default', // 'env' | 'global' | 'tenant' | 'user' | 'default'
// locked: false,
// cascadeChain: [ { scope: 'default', value: 1000, effective: true } ],
// }
// Resolve a whole namespace — manifest + every effective value.
const payload = await settings.getNamespace('crm', { userId: session.userId });
// { manifest: SettingsManifest, values: Record< string, ResolvedSettingValue > }cascadeChain is the source-attribution surface: it carries one entry per layer
that contributed, in declared order, with the winning entry flagged
effective: true and any lock reason attached. That is what the Setup UI renders
as "Inherited from Global" / "Locked by Global" badges, and what an operator
reads when a value is not what they expected.
For code that reads the same namespace repeatedly, createClient() keeps a
snapshot that refreshes on every settings:changed event:
const client = await settings.createClient('crm', { ctx: { userId } });
client.get('enable_scoring'); // synchronous read off the snapshot
client.onChange(() => { /* re-render */ });
client.dispose();Reading an unregistered namespace throws UnknownNamespaceError
(SETTINGS_UNKNOWN_NAMESPACE); reading a key the manifest never declared throws
UnknownKeyError (SETTINGS_UNKNOWN_KEY). There is no "read anything" mode —
a key that no manifest declares does not resolve.
Writing Configuration
// One key.
await settings.set('crm', 'enable_scoring', false, { userId: session.userId });
// A batch — validated and locked-checked as a unit before anything is written.
await settings.setMany('crm', {
max_accounts_per_user: 500,
sync_interval: 'weekly',
}, { userId: session.userId });
// Clear every stored row in the namespace; the cascade falls back to defaults.
await settings.resetNamespace('crm', { userId: session.userId });Writes fail loudly rather than silently degrading:
| Condition | Thrown | Code |
|---|---|---|
An OS_* override is in force for the key | SettingsLockedError | SETTINGS_LOCKED |
A row at an upper scope has locked=true | SettingsLockedError | SETTINGS_LOCKED (locked-by-<scope>) |
| Key not declared by the manifest | UnknownKeyError | SETTINGS_UNKNOWN_KEY |
| Namespace has no manifest | UnknownNamespaceError | SETTINGS_UNKNOWN_NAMESPACE |
| Patch would leave a visible required field empty, or violates a declared constraint | SettingsValidationError | per-key FieldError list (ADR-0114) |
Scope is declared
There is no setTenant() and no setUserPreference(), by design. The scope a
write lands at comes from the manifest, not from the caller:
specifier.scope (falling back to the manifest's scope, which itself defaults
to 'tenant'). set() looks the scope up and writes there.
That is the property that makes the cascade auditable. If callers picked the
scope per write, the same key could acquire rows at three scopes from three code
paths and no reader could tell which layer was authoritative. Narrow a key
further with availableScopes — e.g. ['global'] for a platform-only knob, so
the UI hides tenant and user override affordances entirely.
The Environment Layer
An OS_* variable is the deployment-owned top of the cascade. Its name is
derived mechanically from (namespace, key) by envKeyOf: uppercase, . and
- replaced with _, prefixed OS_.
# namespace 'crm', key 'api_key'
OS_CRM_API_KEY=sk_live_...
# namespace 'feature_flags', key 'ai-enabled'
OS_FEATURE_FLAGS_AI_ENABLED=trueThe raw string is coerced by the type of the specifier's default
(coerceEnvValue):
OS_HTTP_TIMEOUT=30000 # default is a number → 30000
OS_FEATURE_NEWUI=true # default is a boolean → true ('1' / 'yes' also truthy)
OS_ALLOWED_ORIGINS=["a","b","c"] # default is array/object → JSON.parseIf the raw value cannot be parsed for the expected type, the raw string is used.
A value outside a declared option table is ignored, not repaired. When the
specifier declares options and the env value matches none of them, the env
layer contributes nothing at all — no value and no cascadeChain entry — and
the service logs one error line naming the variable and the rejected value.
Guessing which option a typo meant would be worse than not applying it, and an
env entry claiming locked: true while supplying no value would misreport the
cascade to every caller.
Tenant and User Scope
Both live in the same table. Row identity is
(namespace, key, scope, user_id), enforced by a composite unique index:
scope='global'— platform-wide;user_idis null.scope='tenant'— the caller's tenant;user_idis null. The tenant is resolved by the engine's tenant scoping from the caller's session, not by a column the caller supplies.scope='user'—user_idis pinned fromctx.userId, a lookup tosys_user.
sys_setting carries no tenant column of its own. Platform-wide, the tenant
identity is the organization: a session carries organizationId, and where
an object does declare a driver-layer tenant_id column the engine stamps it
from that session value on insert — sys_audit_log.tenant_id, for instance, is
Field.lookup('sys_organization', …). There is no free-form tenant slug and no
per-namespace tenant-config table; plugins MUST NOT define one
(sys_mail_config and friends are exactly the anti-pattern sys_setting
exists to prevent).
Secrets
A specifier marked encrypted (implicit for type: 'password') never stores
plaintext. SettingsService.set() hands the value to the configured
ICryptoProvider, persists the ciphertext as a sys_secret row, and keeps only
the opaque handle in sys_setting.value_enc — sys_setting.value stays null.
Audit rows and history snapshots record a digest and an '<encrypted>'
placeholder, never the plaintext.
The default provider is LocalCryptoProvider: AES-256-GCM keyed off
OS_SECRET_KEY (or a persisted dev key outside production), which fails loud
in production rather than minting an ephemeral key that would silently orphan
every stored secret on restart.
ICryptoProvider is the swap point for managed key custody — a host supplies a
KMS- or Vault-backed implementation through SettingsServiceOptions and
SettingsService is untouched. CryptoHandle carries kmsKeyId, alg and a
monotonic version so rotateKey() can re-wrap under a new key; the audit trail
records the rotation. ObjectStack bundles no cloud provider implementation — see
Outside this contract.
HTTP Surface
registerSettingsRoutes() mounts the resolver under /api/settings
(override via basePath):
| Method | Path | Returns |
|---|---|---|
GET | /api/settings | Manifests visible to the caller |
GET | /api/settings/:namespace | { manifest, values } |
PUT | /api/settings/:namespace | Batch upsert, same semantics as setMany |
POST | /api/settings/:namespace/:actionId | Invoke a declared action_button handler |
Requests that arrive across this boundary are marked enforced, which makes the
service check the manifest's readPermission / writePermission instead of the
trusted in-process pass-through. An in-process caller
(kernel.getService('settings') at boot or seed time) keeps full access.
The sys_setting object also exposes get / list through the data API for the
admin grid in Setup. That grid is diagnostic only — writes must go through
/api/settings/:namespace so the resolver, validation and audit hooks fire.
Outside this contract
These are not features awaiting a release note; they are simply not part of how ObjectStack resolves configuration today:
- Config files are not a settings layer.
objectstack.config.{ts,js,mjs}is loaded by the CLI and declares metadata (objects,apps,views,datasources,plugins, …). It is not read bySettingsService, there is no YAML or JSON config file, and there is noNODE_ENV-selectedobjectstack.config.<env>.ts. KeysdefineStack()does not declare are warned about and dropped. - No merge semantics. The cascade selects one layer's value; it does not deep merge objects. (Stack composition is a different operation with different rules — there, array collections concatenate.)
- No
os configCLI topic. Configuration is validated as part ofos validateand inspected viaos info/os doctor. - No bundled external secret manager. AWS / GCP / Azure / Vault custody is
reached by implementing
ICryptoProvider; no provider ships in the box and there is nosecretskey ondefineStack().
Summary
- Five layers, walked highest-first: env → global → tenant → user → default.
- First non-null wins. No merging; a lock above pins the value below.
- Scope is declared by the manifest, never chosen at the call site.
- One store (
sys_setting) for every namespace; encrypted values indirect throughsys_secret. - Source attribution is part of the read —
cascadeChainsays which layer won and why.
Next: Learn about internationalization in i18n Standard.