ObjectStackObjectStack

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 carry locked=true, and then a write against any lower scope is rejected with SETTINGS_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:

ConditionThrownCode
An OS_* override is in force for the keySettingsLockedErrorSETTINGS_LOCKED
A row at an upper scope has locked=trueSettingsLockedErrorSETTINGS_LOCKED (locked-by-<scope>)
Key not declared by the manifestUnknownKeyErrorSETTINGS_UNKNOWN_KEY
Namespace has no manifestUnknownNamespaceErrorSETTINGS_UNKNOWN_NAMESPACE
Patch would leave a visible required field empty, or violates a declared constraintSettingsValidationErrorper-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=true

The 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.parse

If 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_id is null.
  • scope='tenant' — the caller's tenant; user_id is 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_id is pinned from ctx.userId, a lookup to sys_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_encsys_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):

MethodPathReturns
GET/api/settingsManifests visible to the caller
GET/api/settings/:namespace{ manifest, values }
PUT/api/settings/:namespaceBatch upsert, same semantics as setMany
POST/api/settings/:namespace/:actionIdInvoke 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 by SettingsService, there is no YAML or JSON config file, and there is no NODE_ENV-selected objectstack.config.<env>.ts. Keys defineStack() 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 config CLI topic. Configuration is validated as part of os validate and inspected via os 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 no secrets key on defineStack().

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 through sys_secret.
  • Source attribution is part of the readcascadeChain says which layer won and why.

Next: Learn about internationalization in i18n Standard.

On this page