Configuration System: Schema Validation, Migrations, and Multi-Source Merging

A deep dive into Claude Code's configuration architecture -- Zod schemas, multi-source priority merging, versioned migrations, and remote managed settings

The Problem

Configuration management sounds simple -- just read a JSON file, right? But when you need to support all of the following scenarios, complexity grows exponentially:

  • User global settings (~/.claude/settings.json)
  • Project shared settings (.claude/settings.json, committed to git)
  • Project local settings (.claude/settings.local.json, gitignored)
  • CLI flag overrides (--settings parameter)
  • Enterprise managed policies (MDM push or remote API)
  • Remote managed settings (organization-level config fetched from API)
  • Real-time hot reloading on config changes
  • Priority-based merging across multiple sources
  • Automatic migration from old formats to new ones

Claude Code's configuration system validates every layer with Zod schemas, merges 5 sources using deterministic priority rules, and handles historical compatibility through 11 migration functions. This article dives into the implementation of each layer.

Configuration Sources and Priority

...

src/utils/settings/constants.ts defines the source priorities:

TypeScript
1// src/utils/settings/constants.ts Lines 7-22
2export const SETTING_SOURCES = [
3 'userSettings', // User global settings
4 'projectSettings', // Project shared settings
5 'localSettings', // Project local settings (gitignored)
6 'flagSettings', // CLI --settings flag
7 'policySettings', // Enterprise managed policies
8] as const

The order determines priority -- later sources override earlier ones. This means:

  1. User settings form the base layer
  2. Project settings override user preferences (team conventions)
  3. Local settings override project settings (personal overrides)
  4. CLI flags override all file-based config (temporary overrides)
  5. Policy settings have the highest priority (enterprise enforcement)

Source Types

TypeScript
1// src/utils/settings/constants.ts Lines 24, 182-185
2export type SettingSource = (typeof SETTING_SOURCES)[number]
3
4export type EditableSettingSource = Exclude<
5 SettingSource,
6 'policySettings' | 'flagSettings'
7>

EditableSettingSource excludes policy and flag sources -- users cannot edit managed policies or config generated from CLI flags. Only userSettings, projectSettings, and localSettings can be modified through the /config command or by directly editing files.

Source Enable Control

TypeScript
1// src/utils/settings/constants.ts Lines 159-167
2export function getEnabledSettingSources(): SettingSource[] {
3 const allowed = getAllowedSettingSources()
4 // Policy and flag sources are always enabled
5 const result = new Set<SettingSource>(allowed)
6 result.add('policySettings')
7 result.add('flagSettings')
8 return Array.from(result)
9}

Even when sources are restricted via --setting-sources, policy and flag settings always remain active. This ensures enterprise managed policies cannot be bypassed.

Zod Schema Validation

src/utils/settings/types.ts defines the complete configuration schema.

Permissions Schema

TypeScript
1// src/utils/settings/types.ts Lines 42-85
2export const PermissionsSchema = lazySchema(() =>
3 z.object({
4 allow: z.array(PermissionRuleSchema()).optional()
5 .describe('List of permission rules for allowed operations'),
6 deny: z.array(PermissionRuleSchema()).optional()
7 .describe('List of permission rules for denied operations'),
8 ask: z.array(PermissionRuleSchema()).optional()
9 .describe('List of permission rules that should always prompt'),
10 defaultMode: z.enum(
11 feature('TRANSCRIPT_CLASSIFIER')
12 ? PERMISSION_MODES
13 : EXTERNAL_PERMISSION_MODES,
14 ).optional(),
15 disableBypassPermissionsMode: z.enum(['disable']).optional(),
16 ...(feature('TRANSCRIPT_CLASSIFIER')
17 ? { disableAutoMode: z.enum(['disable']).optional() }
18 : {}),
19 additionalDirectories: z.array(z.string()).optional(),
20 }).passthrough(),
21)

Note two key design decisions:

  1. feature() compile-time conditionals -- The TRANSCRIPT_CLASSIFIER flag controls whether the auto mode schema is included. In external builds, the disableAutoMode field doesn't exist in the schema at all.
  2. .passthrough() -- Allows unknown fields to pass validation, ensuring forward compatibility. Adding new fields in future versions won't cause errors in older versions.

Hook Schema

TypeScript
1// src/schemas/hooks.ts Lines 32-171 (core section)
2function buildHookSchemas() {
3 const BashCommandHookSchema = z.object({
4 type: z.literal('command'),
5 command: z.string(),
6 if: IfConditionSchema(),
7 shell: z.enum(SHELL_TYPES).optional(),
8 timeout: z.number().positive().optional(),
9 statusMessage: z.string().optional(),
10 once: z.boolean().optional(),
11 async: z.boolean().optional(),
12 asyncRewake: z.boolean().optional(),
13 })
14
15 const PromptHookSchema = z.object({
16 type: z.literal('prompt'),
17 prompt: z.string(),
18 if: IfConditionSchema(),
19 timeout: z.number().positive().optional(),
20 model: z.string().optional(),
21 statusMessage: z.string().optional(),
22 once: z.boolean().optional(),
23 })
24
25 const HttpHookSchema = z.object({
26 type: z.literal('http'),
27 url: z.string().url(),
28 if: IfConditionSchema(),
29 timeout: z.number().positive().optional(),
30 headers: z.record(z.string(), z.string()).optional(),
31 allowedEnvVars: z.array(z.string()).optional(),
32 statusMessage: z.string().optional(),
33 once: z.boolean().optional(),
34 })
35
36 const AgentHookSchema = z.object({
37 type: z.literal('agent'),
38 prompt: z.string(),
39 if: IfConditionSchema(),
40 timeout: z.number().positive().optional(),
41 model: z.string().optional(),
42 statusMessage: z.string().optional(),
43 once: z.boolean().optional(),
44 })
45
46 return { BashCommandHookSchema, PromptHookSchema, HttpHookSchema, AgentHookSchema }
47}

Hooks use Zod's discriminatedUnion, differentiating four types by the type field:

TypeScript
1// src/schemas/hooks.ts Lines 176-189
2export const HookCommandSchema = lazySchema(() => {
3 const { BashCommandHookSchema, PromptHookSchema, AgentHookSchema, HttpHookSchema }
4 = buildHookSchemas()
5 return z.discriminatedUnion('type', [
6 BashCommandHookSchema,
7 PromptHookSchema,
8 AgentHookSchema,
9 HttpHookSchema,
10 ])
11})

The lazySchema Pattern

Notice that all schemas are wrapped with lazySchema. This is a lazy evaluation wrapper -- schemas are only constructed on first invocation, avoiding expensive Zod type building during module loading. For CLI startup speed, this is an important optimization.

Environment Variables Schema

TypeScript
1// src/utils/settings/types.ts Lines 35-37
2export const EnvironmentVariablesSchema = lazySchema(() =>
3 z.record(z.string(), z.coerce.string()),
4)

z.coerce.string() means that even if values are numbers or booleans, they'll be coerced to strings. This aligns with environment variable semantics -- all environment variables are fundamentally strings.

Configuration File Loading and Merging

src/utils/settings/settings.ts implements configuration reading and merging.

File Managed Settings

TypeScript
1// src/utils/settings/settings.ts Lines 74-100 (loadManagedFileSettings)
2export function loadManagedFileSettings(): {
3 settings: SettingsJson | null
4 errors: ValidationError[]
5} {
6 const errors: ValidationError[] = []
7 let merged: SettingsJson = {}
8 let found = false
9
10 // 1. Load base file
11 const { settings, errors: baseErrors } = parseSettingsFile(
12 getManagedSettingsFilePath()
13 )
14 errors.push(...baseErrors)
15 if (settings && Object.keys(settings).length > 0) {
16 merged = mergeWith(merged, settings, settingsMergeCustomizer)
17 found = true
18 }
19
20 // 2. Load drop-in directory
21 const dropInDir = getManagedSettingsDropInDir()
22 try {
23 const entries = getFsImplementation()
24 .readdirSync(dropInDir)
25 .filter(d =>
26 (d.isFile() || d.isSymbolicLink()) &&
27 d.name.endsWith('.json') &&
28 !d.name.startsWith('.')
29 )
30 // Sorted alphabetically -- later files have higher priority
31 // ...
32 }
33}

Managed settings support two forms:

  1. Single file -- managed-settings.json, serving as the base
  2. Drop-in directory -- managed-settings.d/*.json, merged in alphabetical order

This design borrows from systemd's drop-in convention: different teams can independently deploy policy fragments (e.g., 10-otel.json, 20-security.json) without coordinating edits to the same file.

MDM (Mobile Device Management) Integration

TypeScript
1// From settings.ts Lines 36-37, MDM imports
2import { getHkcuSettings, getMdmSettings } from './mdm/settings.js'

Claude Code also supports OS-level MDM configuration distribution:

  • macOS -- Distributed via MDM profiles to /Library/Managed Preferences/
  • Windows -- Via HKCU registry keys

These all fall under the policySettings source and are merged with file managed settings.

Multi-Source Merging

Configuration merging uses lodash's mergeWith with a custom merge strategy:

...

The key distinction in the merge strategy:

  • Permission rule arrays (allow, deny, ask) -- Concatenated. A project's allow rules are appended after the user's allow rules, not replaced.
  • Other arrays -- Replaced. For example, additionalDirectories from a later source completely overrides the earlier one.
  • Objects -- Deep merged. Nested fields are overridden individually.

Settings Cache

TypeScript
1// From settings.ts Lines 40-46, cache imports
2import {
3 getCachedParsedFile,
4 getCachedSettingsForSource,
5 getSessionSettingsCache,
6 resetSettingsCache,
7 setCachedParsedFile,
8 setCachedSettingsForSource,
9 setSessionSettingsCache,
10} from './settingsCache.js'

Configuration reading uses multi-level caching:

  1. File parse cache -- Each file path is parsed only once
  2. Source cache -- Settings for each source are computed only once
  3. Session cache -- The final merged result is computed only once

When any source file changes, the cache is selectively invalidated and rebuilt.

Settings Change Detection

From settings.ts line 27, the import:

TypeScript
1import { settingsChangeDetector } from '../../utils/settings/changeDetector.js'

changeDetector uses filesystem watchers (e.g., fs.watch) to detect settings file changes. When a change is detected:

  1. The modified file is re-parsed
  2. Affected cache layers are invalidated
  3. The useSettingsChange callback is triggered
  4. AppState is updated via store.setState
  5. onChangeAppState handles side effects (such as clearing auth caches)

Versioned Migrations

The src/migrations/ directory contains 11 migration functions that handle the historical evolution of configuration formats.

Migration List

Text
1migrateAutoUpdatesToSettings.ts -- Auto-update preference migration to settings.json
2migrateBypassPermissionsAcceptedToSettings.ts -- Permission bypass setting migration
3migrateEnableAllProjectMcpServersToSettings.ts -- MCP server enable setting migration
4migrateFennecToOpus.ts -- Fennec model alias migration to Opus
5migrateLegacyOpusToCurrent.ts -- Legacy Opus name migration
6migrateOpusToOpus1m.ts -- Opus -> Opus[1m] migration
7migrateReplBridgeEnabledToRemoteControlAtStartup.ts -- Bridge setting migration
8migrateSonnet1mToSonnet45.ts -- Sonnet 1m -> Sonnet 4.5 migration
9migrateSonnet45ToSonnet46.ts -- Sonnet 4.5 -> Sonnet 4.6 migration
10resetAutoModeOptInForDefaultOffer.ts -- Auto mode opt-in reset
11resetProToOpusDefault.ts -- Pro user default model reset

Migration Example: Auto Updates

TypeScript
1// src/migrations/migrateAutoUpdatesToSettings.ts Lines 13-61
2export function migrateAutoUpdatesToSettings(): void {
3 const globalConfig = getGlobalConfig()
4
5 // Only migrate when the user explicitly disabled auto-updates
6 if (
7 globalConfig.autoUpdates !== false ||
8 globalConfig.autoUpdatesProtectedForNative === true
9 ) {
10 return
11 }
12
13 try {
14 const userSettings = getSettingsForSource('userSettings') || {}
15
16 // Migrate to env variable
17 updateSettingsForSource('userSettings', {
18 ...userSettings,
19 env: {
20 ...userSettings.env,
21 DISABLE_AUTOUPDATER: '1',
22 },
23 })
24
25 logEvent('tengu_migrate_autoupdates_to_settings', {
26 was_user_preference: true,
27 already_had_env_var: !!userSettings.env?.DISABLE_AUTOUPDATER,
28 })
29
30 // Take effect immediately
31 process.env.DISABLE_AUTOUPDATER = '1'
32
33 // Remove from old config
34 saveGlobalConfig(current => {
35 const { autoUpdates: _, autoUpdatesProtectedForNative: __, ...rest } = current
36 return rest
37 })
38 } catch (error) {
39 logError(new Error(`Failed to migrate auto-updates: ${error}`))
40 }
41}

Key characteristics of migrations:

  1. Idempotent -- Running multiple times produces no side effects
  2. Conditional execution -- Only triggered when the old format is detected
  3. Atomic -- Writes the new config first, then deletes the old one
  4. Observable -- Migration events are recorded via logEvent

Migration Example: Model Aliases

TypeScript
1// src/migrations/migrateFennecToOpus.ts Lines 18-45
2export function migrateFennecToOpus(): void {
3 // Internal users only
4 if (process.env.USER_TYPE !== 'ant') return
5
6 const settings = getSettingsForSource('userSettings')
7 const model = settings?.model
8
9 if (typeof model === 'string') {
10 if (model.startsWith('fennec-latest[1m]')) {
11 updateSettingsForSource('userSettings', { model: 'opus[1m]' })
12 } else if (model.startsWith('fennec-latest')) {
13 updateSettingsForSource('userSettings', { model: 'opus' })
14 } else if (
15 model.startsWith('fennec-fast-latest') ||
16 model.startsWith('opus-4-5-fast')
17 ) {
18 updateSettingsForSource('userSettings', {
19 model: 'opus[1m]',
20 fastMode: true,
21 })
22 }
23 }
24}

This migration demonstrates two important decisions:

  1. Only migrate userSettings -- Never touches project/local/policy settings. A source code comment explains why: "Reading merged settings would cause infinite re-runs + silent global promotion."
  2. Model remapping -- fennec-fast-latest maps to opus[1m] + fastMode: true, preserving the user's performance preference.

Migration Execution Timing

Migrations execute during the preAction phase in main.tsx:

TypeScript
1// profileCheckpoint calls in main.tsx showing order
2profileCheckpoint('preAction_after_mdm') // Line 915
3profileCheckpoint('preAction_after_init') // Line 917
4profileCheckpoint('preAction_after_sinks') // Line 935
5profileCheckpoint('preAction_after_migrations') // Line 951 <- Migrations complete here
6profileCheckpoint('preAction_after_remote_settings') // Line 959

Migrations execute after sink initialization but before remote settings are loaded -- this means migrations can use telemetry (to record migration events) but don't depend on remote settings.

Remote Managed Settings

src/services/remoteManagedSettings/index.ts implements fetching enterprise-level managed settings from an API.

TypeScript
1// src/services/remoteManagedSettings/index.ts Lines 1-13
2/**
3 * Remote Managed Settings Service
4 *
5 * Manages fetching, caching, and validation of remote-managed settings
6 * for enterprise customers. Uses checksum-based validation to minimize
7 * network traffic and provides graceful degradation on failures.
8 *
9 * Eligibility:
10 * - Console users (API key): All eligible
11 * - OAuth users (Claude.ai): Only Enterprise/C4E and Team subscribers
12 * - API fails open (non-blocking)
13 * - API returns empty settings for users without managed settings
14 */

Checksum Caching

Remote configuration uses a checksum mechanism to reduce network traffic. Similar to HTTP ETags, but based on SHA256 content hashing:

  1. First fetch -> store config + compute checksum
  2. Subsequent fetches -> include checksum, server compares
  3. If unchanged -> 304 Not Modified, use cache
  4. If changed -> 200 + new config

Security Checks

TypeScript
1// src/services/remoteManagedSettings/index.ts Lines 38-39
2import {
3 checkManagedSettingsSecurity,
4 handleSecurityCheckResult,
5} from './securityCheck.jsx'

Remote configuration must pass security checks before being applied. securityCheck.tsx ensures remote config cannot introduce dangerous operations -- for example, remote config should not be able to set arbitrary env variables or modify permission deny rules.

Background Polling

TypeScript
1// src/services/remoteManagedSettings/index.ts Lines 54-55
2const POLLING_INTERVAL_MS = 60 * 60 * 1000 // 1 hour

Remote config is polled once per hour. The loading process is non-blocking -- if the API is unreachable, it continues running with the cache or without remote config.

Policy Limits

src/services/policyLimits/index.ts is another enterprise configuration layer -- organization-level feature restrictions.

TypeScript
1// src/services/policyLimits/index.ts Lines 510-526
2export function isPolicyAllowed(policy: string): boolean {
3 const restrictions = getRestrictionsFromCache()
4 if (!restrictions) {
5 // Safe degradation in HIPAA mode
6 if (isEssentialTrafficOnly() && ESSENTIAL_TRAFFIC_DENY_ON_MISS.has(policy)) {
7 return false
8 }
9 return true // fail open
10 }
11 const restriction = restrictions[policy]
12 if (!restriction) return true // Unknown policy = allow
13 return restriction.allowed
14}

Design principles for policy limits:

  1. Fail open -- Allow by default. Network failures should not prevent users from using the CLI.
  2. HIPAA exception -- For essential-traffic-only mode, specific policies (like allow_product_feedback) default to deny when the cache is unavailable.
TypeScript
1// src/services/policyLimits/index.ts Line 502
2const ESSENTIAL_TRAFFIC_DENY_ON_MISS = new Set(['allow_product_feedback'])

Cache Architecture

...

Three-level cache:

  1. Session cache -- In-memory sessionCache, fastest
  2. Disk cache -- ~/.claude/policy-limits.json, survives process restarts
  3. Network fetch -- API request with retry and exponential backoff

ETag Caching

TypeScript
1// src/services/policyLimits/index.ts Lines 132-159
2function computeChecksum(
3 restrictions: PolicyLimitsResponse['restrictions'],
4): string {
5 const sorted = sortKeysDeep(restrictions)
6 const normalized = jsonStringify(sorted)
7 const hash = createHash('sha256').update(normalized).digest('hex')
8 return `sha256:${hash}`
9}

The checksum is computed from normalized JSON -- all keys are recursively sorted first, then serialized, then hashed. This ensures that even if the server returns fields in a different order, the checksum will be identical as long as the content is the same.

Authentication Support

TypeScript
1// src/services/policyLimits/index.ts Lines 227-262
2function getAuthHeaders(): { headers: Record<string, string>; error?: string } {
3 // Try API key first (Console users)
4 try {
5 const { key: apiKey } = getAnthropicApiKeyWithSource({
6 skipRetrievingKeyFromApiKeyHelper: true,
7 })
8 if (apiKey) {
9 return { headers: { 'x-api-key': apiKey } }
10 }
11 } catch { /* Fall through to OAuth */ }
12
13 // Fall back to OAuth tokens (Claude.ai users)
14 const oauthTokens = getClaudeAIOAuthTokens()
15 if (oauthTokens?.accessToken) {
16 return {
17 headers: {
18 Authorization: `Bearer ${oauthTokens.accessToken}`,
19 'anthropic-beta': OAUTH_BETA_HEADER,
20 },
21 }
22 }
23
24 return { headers: {}, error: 'No authentication available' }
25}

The policy limits API supports two authentication methods:

  1. API key -- Console users use the x-api-key header directly
  2. OAuth -- Claude.ai users use Bearer tokens

skipRetrievingKeyFromApiKeyHelper: true avoids triggering the API key helper's execution -- on high-frequency paths like policy limit checks, launching an external process to retrieve a key is undesirable.

Initialization Loading Promise

TypeScript
1// src/services/policyLimits/index.ts Lines 94-114
2export function initializePolicyLimitsLoadingPromise(): void {
3 if (loadingCompletePromise) return
4
5 if (isPolicyLimitsEligible()) {
6 loadingCompletePromise = new Promise(resolve => {
7 loadingCompleteResolve = resolve
8
9 // Deadlock prevention timeout
10 setTimeout(() => {
11 if (loadingCompleteResolve) {
12 loadingCompleteResolve()
13 loadingCompleteResolve = null
14 }
15 }, LOADING_PROMISE_TIMEOUT_MS) // 30 seconds
16 })
17 }
18}

Both remote managed settings and policy limits use the same Promise pattern:

  1. Create the Promise early during initialization
  2. Other systems can await waitForPolicyLimitsToLoad() to wait for loading to complete
  3. A 30-second timeout prevents deadlocks -- if loadPolicyLimits() is never called (e.g., in Agent SDK tests), the Promise resolves automatically

Configuration Validation and Error Handling

Configuration file parsing is not a simple JSON.parse. Every file undergoes full Zod schema validation:

  1. JSON parsing -- The file may contain invalid JSON
  2. Schema validation -- Field type, format, and range checks
  3. Permission rule filtering -- Invalid permission rules are filtered out rather than rejecting the entire file
  4. Error collection -- All validation errors are collected without interrupting loading

The core principle of this design is resilience -- a malformed configuration file should not prevent the CLI from starting. Invalid rules are skipped while valid rules continue to take effect.

Source Display Names

TypeScript
1// src/utils/settings/constants.ts Lines 26-93
2export function getSettingSourceName(source: SettingSource): string {
3 switch (source) {
4 case 'userSettings': return 'user'
5 case 'projectSettings': return 'project'
6 case 'localSettings': return 'project, gitignored'
7 case 'flagSettings': return 'cli flag'
8 case 'policySettings': return 'managed'
9 }
10}
11
12export function getSourceDisplayName(
13 source: SettingSource | 'plugin' | 'built-in',
14): string {
15 switch (source) {
16 case 'userSettings': return 'User'
17 case 'projectSettings': return 'Project'
18 case 'localSettings': return 'Local'
19 case 'flagSettings': return 'Flag'
20 case 'policySettings': return 'Managed'
21 case 'plugin': return 'Plugin'
22 case 'built-in': return 'Built-in'
23 }
24}

Multiple display name formats are provided -- short names (for UI labels), descriptive names (for inline text), and capitalized names (for context/skill UI). This ensures configuration sources can be clearly identified across different UI scenarios.

Summary

Claude Code's configuration system is an engineering masterpiece:

  • 5-source priority merging -- user < project < local < flag < policy, with policies that cannot be bypassed
  • Zod schema validation -- Compile-time feature flags control schema shape, lazySchema for lazy construction
  • Drop-in directories -- Borrowing from the systemd convention, allowing multiple teams to independently deploy policy fragments
  • 11 versioned migrations -- Idempotent, conditionally executed, atomically updated, and observable
  • Remote managed settings -- Checksum caching, ETag optimization, fail-open strategy
  • Policy limits -- Three-level cache, HIPAA safe degradation, dual authentication support
  • Configuration hot reload -- File watch -> cache invalidation -> Store update -> side effect execution