Skip to main content

ADR 025: Configuration Option Naming Convention

Status

✅ Accepted (2026-08-11)

Context

The configuration organization research (started 2025-11-09, since deleted, with the Related list below pointing at its git history) audited the flat yargs-based configuration and found related options scattered across categories, negative and positive naming mixed (disableNotifications beside trayIconEnabled), inconsistent abbreviations (customBGServiceBaseUrl beside isCustomBackgroundEnabled), and no rule for when options should become a nested object. It settled a convention and a target layout, and its 2026-01-18 conclusion rejected a big-bang migration in favour of incremental evolution: new features use nested keys from day one, existing flat options migrate opportunistically as their modules are refactored. That has run for months, and several options the research tracked as deprecated have since been removed outright, making hard removal rather than long-lived aliasing the precedent (see app/intune/README.md). Today app/config/options.js declares 72 top-level options, 17 object-typed with fields metadata and 55 still flat, with docs-site/static/config-schema.json the generated source of truth.

Decision

Naming convention

Rule zero: new options are always nested, and the flat top-level namespace is closed to additions. First check docs-site/static/config-schema.json (or app/config/options.js) for a namespace that already owns your feature area and add your leaf there; create a new parent only when none fits.

Nest under an object when any of these hold: three or more options relate to one feature, options share a common prefix, an option only matters when a sibling gate is enabled, or they describe a single conceptual feature. Dependents always nest under their gate (mqtt.brokerUrl under mqtt.enabled).

Master feature gates use positive naming: the gate leaf is enabled, never a disable* name, and a gate earns its own object only when it has dependent siblings; a standalone boolean uses a positive <feature>Enabled-style leaf instead. Both forms ship today: media.camera.resolution.enabled gates its mode, width and height siblings, while media.video.menuEnabled stands alone. Escape hatches that exist purely to switch a platform behaviour off (disableGpu, disableTimestampOnCopy, network.disableQuic) keep negative names, since inverting them obscures the workaround. A few shipped leaves (media.microphone.disableAutogain, media.preventDeviceSwitching) carry negative names predating this ADR and are not precedent.

Spell words out rather than abbreviating (customBackground, not customBG), and rely on the nesting to keep full names short (tray.enabled rather than trayIconEnabled).

Resolved rename mapping

The table maps every flat option to its nested target. Four renames invert a boolean, marked in the Inverted column, so tooling must negate those values rather than copy them; a blank cell means copy unchanged. For example disableNotifications: true becomes notifications.enabled: false, and defaults stay behaviour-preserving under inversion (disableNotifications defaults to false, so notifications.enabled defaults to true).

The table ships in batches. The shortcuts and storage renames landed in 2.17.0, and the ten remaining brand-new namespaces are in flight; the rest are still flat-only, and naming one of those nested targets today gets the key ignored with a startup warning. #2841 settled what happens to the old name: both spellings keep working until the flat declarations are removed in 2.30.0, and app/config/renames.js projects a nested value onto the flat key that modules still read. A deprecated key in a config file is reported at startup by checkUsedDeprecatedValues, and each batch is announced in the release notes.

The notifications, idleDetection, network and auth targets are shipped objects that already hold unrelated leaves; merging renamed options into them is intentional, and every leaf key below was checked against the shipped fields with no collisions.

Flat optionNested targetInverted
appTitleapp.title
urlapp.url
partitionapp.partition
framewindow.frame
menubarwindow.menubar
minimizedwindow.minimized
closeAppOnCrosswindow.closeOnCross
minimizeOnClosewindow.minimizeOnClose
alwaysOnTopwindow.alwaysOnTop
classwindow.class
customCSSNameappearance.cssName
customCSSLocationappearance.cssLocation
followSystemThemeappearance.followSystemTheme
trayIconEnabledtray.enabled
appIcontray.icon
appIconTypetray.iconType
useMutationTitleLogictray.useMutationTitleLogic
disableNotificationsnotifications.enabledYes
disableNotificationSoundnotifications.sound.enabledYes
disableNotificationSoundIfNotAvailablenotifications.sound.onlyWhenAvailable
disableNotificationWindowFlashnotifications.windowFlashYes
disableBadgeCountnotifications.badgeCountEnabledYes
notificationMethodnotifications.method
defaultNotificationUrgencynotifications.urgency
enableIncomingCallToastincomingCalls.toast
incomingCallCommandincomingCalls.command
incomingCallCommandArgsincomingCalls.commandArgs
awayOnSystemIdleidleDetection.setAwayOnIdle
appIdleTimeoutidleDetection.timeout
appIdleTimeoutCheckIntervalidleDetection.checkInterval.detectIdle
appActiveCheckIntervalidleDetection.checkInterval.detectActive
authServerWhitelistauth.serverWhitelist
ssoBasicAuthUserauth.basic.user
ssoBasicAuthPasswordCommandauth.basic.passwordCommand
clientCertPathauth.clientCertificate.path
clientCertPasswordauth.clientCertificate.password
customCACertsFingerprintsauth.customCACertificateFingerprints
proxyServernetwork.proxyServer
isCustomBackgroundEnabledcustomBackground.enabled
customBGServiceBaseUrlcustomBackground.serviceBaseUrl
customBGServiceConfigFetchIntervalcustomBackground.configFetchInterval
defaultURLHandlerurlHandling.defaultHandler
meetupJoinRegExurlHandling.meetupJoinRegEx
onNewWindowOpenMeetupJoinUrlInAppurlHandling.openMeetupJoinInApp
globalShortcutsshortcuts.global
disableGlobalShortcutsshortcuts.disableWhileFocused
disableGpuperformance.disableGpu
electronCLIFlagsperformance.electronCLIFlags
clearStorageDatastorage.clearData
webDebugdevelopment.webDebug
watchConfigFiledevelopment.watchConfigFile
chromeUserAgentplatform.chromeUserAgent
emulateWinChromiumPlatformplatform.emulateWindowsChromium
spellCheckerLanguagesplatform.spellCheckerLanguages
disableTimestampOnCopyplatform.disableTimestampOnCopy

Notes on specific rows. disableGlobalShortcuts is an array of accelerators to disable while the app is focused, not a boolean, so its rename clarifies rather than inverts. disableNotificationSoundIfNotAvailable is not inverted either: true already means the sound plays only while status is Available, exactly what onlyWhenAvailable: true means. The idleDetection.checkInterval leaves are named for what each poll detects: app/browser/notifications/activityManager.js uses appIdleTimeoutCheckInterval while active (watching for idle onset) and appActiveCheckInterval while idle (watching for the return to activity); plain .idle and .active would plausibly be wired backwards, and app/idle/README.md documented the pair backwards until corrected alongside this ADR. awayOnSystemIdle targets idleDetection.setAwayOnIdle rather than an enabled gate because idleDetection ships as an always-on object with no master switch.

disableBadgeCount and minimizeOnClose had no target in the research mapping and are decided here as notifications.badgeCountEnabled (inverted, suffixed because a plain badgeCount boolean reads as a number) and window.minimizeOnClose. minimizeOnClose overlaps semantically with closeAppOnCross, both changing what the close cross does, and is ignored when closeAppOnCross is true; that is an observation only, and collapsing them is out of scope. storage.clearData corresponds to Electron's session clearStorageData API, the namespace supplying the word storage the leaf would otherwise stutter.

On the auth rows, authServerWhitelist lands at auth.serverWhitelist outside auth.basic, being the Negotiate/NTLM server allowlist rather than a credential. clientCertPath and clientCertPassword target auth.clientCertificate.* rather than the research doc's auth.certificate.*, since auth.clientCertificate.pinDialog.enabled has shipped (ADR-024) and two adjacent certificate namespaces would be worse. auth.basic.* stays distinct from the shipped auth.webLogin.user and auth.webLogin.passwordCommand despite similar leaf names: auth.basic.* feeds Electron's HTTP Basic/NTLM login dialog for proxy and intranet challenges, auth.webLogin.* pre-fills the Microsoft web sign-in form. Both remain.

Alternatives Considered

Re-parenting shipped nested objects

The research mapping also re-parented three already-nested objects: cacheManagement to storage.cacheManagement, logConfig to development.logConfig, msTeamsProtocols to urlHandling.msTeamsProtocols. Rejected: these are already discoverable, correctly grouped objects, and re-parenting a working namespace breaks every existing user config for zero gain, which is pure churn. The storage and development namespaces therefore start smaller than envisioned (storage initially holds only clearData), acceptably so.

Runtime aliasing of old names

An alias layer keeping flat names working forever was rejected by the original research and stays rejected; the precedent is hard removal after a deprecation window, as with ssoInTuneEnabled and its siblings (app/intune/README.md). That rejection is about a permanent layer. Whether a time-boxed fallback reads the old key for one deprecation window is a separate question and is open in #2841.

Consequences

Positive

Contributors get one canonical answer for naming a new option and for where an existing flat option will land, without reading a 1600-line research document. The mapping is stable enough for tooling to consume, and #2841 chose the mechanism: yargs' own deprecated field, which checkUsedDeprecatedValues in app/config/index.js reads on every startup for any deprecated key present in the config file, system-wide or user. That only warns, since it neither maps an old name to its successor nor handles the four inversions, so app/config/renames.js supplies both.

Negative

The four inversions mean a naive key-copy migration would silently flip user intent, so tooling must consult the Inverted column; coerce in app/config/renames.js is what does that, in both directions. The table in this document is hand-maintained, but its code counterpart no longer drifts unnoticed: the config renames - table integrity suite in tests/unit/configRenames.test.js pins every entry against app/config/options.js.

Migration is not full equivalence. A flat option declares a type and yargs coerces the config-file value to it, while a nested leaf is not a declared option, so a config that leans on that coercion ("disableGpu": "false" as a string) changes meaning when rewritten. Tooling should validate its output rather than assume the two files resolve alike.

Neutral

A permanent alias layer stays rejected. The codemod no longer does: toNestedConfigFile in app/config/renames.js rewrites a config file onto the nested names, and #2913 tracks the surface that offers it to users. Renames proceed namespace by namespace as tracked in #2842, and the four occupied namespaces will mix long-shipped and newly-arrived leaves.

  • ADR-024: shipped the auth.clientCertificate namespace this ADR aligns with
  • Roadmap: Config Schema as Single Source of Truth (#2597), where the in-app settings window is tracked
  • #2841, the deprecation window decision that must precede the first rename, and #2842, which tracks the migration itself
  • Documentation, Contributing, and Config UX research, which builds on this convention
  • app/config/options.js and docs-site/static/config-schema.json, the live inventory
  • Research history: see git history for docs-site/docs/development/research/configuration-organization-research.md