Skip to main content

ADR-017: Settings Persistence Architecture

Status

Accepted (Implemented — all 4 phases complete)

Context

Problem Statement

Conductor's SettingsPanel exposes 7 general settings (autoStart, minimizeToTray, eventBufferSize, autoSaveConfigs, checkForUpdates, logLevel, midiLearnTimeout) but they are completely stubbed. The saveSettings() function (SettingsPanel.svelte:154-177) uses setTimeout(500ms) to fake a save and shows "Settings saved successfully" — nothing persists. loadSettings() (SettingsPanel.svelte:141-152) is equally empty. All settings reset to hardcoded defaults on every launch. This actively misleads users into thinking their preferences were saved.

Current Persistence Landscape

The project already has 6 working persistence mechanisms, but no home for application-level settings that span the GUI/daemon boundary:

MechanismWhat It StoresWhere
localStorageTheme, accent color, feedback sounds, panel widthsBrowser storage (Tauri WebView)
System keychainLLM API keysOS credential store (tauri-plugin-keychain)
config.tomlDevice mappings, modes, triggers, actionsPlatform config dir*
state.jsonDaemon runtime state (current mode, last reload)~/.midimon/state.json
audit.dbMCP tool execution audit log~/.midimon/conductor.db
conversations.dbChat history, cost tracking~/.midimon/conversations.db

*Platform config dir: ~/Library/Application Support/midimon/ (macOS), ~/.config/midimon/ (Linux), %APPDATA%\midimon\ (Windows).

Settings like "minimize to tray" and "log level" don't belong in any of these. They aren't mapping configuration (config.toml), runtime state (state.json), visual preferences (localStorage), or secrets (keychain). They need their own persistence layer.

Settings Classification

Not all settings belong in the same place. They divide naturally by ownership boundary:

SettingOwnerWhy
autoStartPlatform APIOS-level launch agent (macOS) / registry (Windows) / systemd (Linux)
minimizeToTrayGUI preferenceOnly the GUI cares about window behavior
autoSaveConfigsGUI preferenceGUI decides when to auto-save
checkForUpdatesGUI preferenceGUI startup check
eventBufferSizeGUI preferenceControls GUI event console buffer
midiLearnTimeoutGUI preferenceGUI MIDI Learn session timeout
daemonBinaryPathGUI preferenceGUI needs to know where to launch daemon
logLevelDaemon operationalDaemon's tracing filter level
usageTrackingDaemon operationalDaemon analytics toggle (#554)
Theme/accent/feedback/layoutlocalStoragePure CSS/DOM state, already working

This yields three categories: GUI preferences (file-persisted), daemon settings (file-persisted), and platform API (OS-managed).

IssueTitleRelationship
#559Document state persistence boundariesEpic — this ADR is the deliverable
#468Settings persistence fully stubbedCore problem — setTimeout(500) fake save
#475General settings not wired to backendAll 4 checkboxes decorative
#474Log level setting has no effectGUI main.rs:66 hardcodes conductor_gui=debug
#472About links wrong URLsSettingsPanel.svelte:678-686 point to wrong org
#469Redundant hasChanges assignmentLines 165+181 manual assignments
#552Can't open config file from GUIBackend exists but not surfaced
#506Tray menu ignores daemon binary pathBlocked by #475
#554Persist mapping fire usage dataIntroduces daemon.toml usage_tracking toggle

Decision

D1: Two New TOML Files — preferences.toml and daemon.toml

Introduce two new files in the existing ~/.midimon/ directory (managed by get_state_dir() in daemon/state.rs:204):

  • preferences.toml — GUI application preferences. Read/written by the Tauri frontend via commands. The daemon never reads this file.
  • daemon.toml — Daemon operational settings. Read by the daemon on startup and on IPC reload command. The GUI reads/writes it via Tauri commands that also notify the running daemon via IPC.

Why two files instead of one? Ownership clarity. The GUI process and daemon process have different lifecycles. A single file would require coordination on concurrent writes (daemon hot-reload vs GUI save). Two files means each process owns exactly one write path, eliminating race conditions.

Why TOML? The project already uses TOML for config.toml. Users who want to hand-edit settings get the same familiar format. Serde support is already in the dependency tree.

D2: config.toml Remains Untouched

Application preferences must never be mixed into config.toml. That file represents mapping intent — what the user wants their controller to do. Mixing operational settings (log level, tray behavior) with mapping configuration would:

  1. Violate single-responsibility — config.toml has a clear schema validated by config/validator.rs
  2. Create confusion about what conductorctl reload affects
  3. Pollute config diffs with non-mapping noise
  4. Break the Plan/Apply workflow which operates on mapping changes

D3: localStorage Stays for Visual/Layout Preferences

Theme, accent color, feedback sounds, and panel widths remain in localStorage. These are pure CSS/DOM concerns with no backend relevance. The existing feedback-settings.js store pattern (auto-persist via store.subscribe()) works well and should not be disrupted.

D4: autoStart Uses Platform API, Not File Persistence

Auto-start is an OS-level concept:

  • macOS: Launch Agent plist in ~/Library/LaunchAgents/
  • Windows: Registry key in HKCU\Software\Microsoft\Windows\CurrentVersion\Run
  • Linux: .desktop file in ~/.config/autostart/

Use tauri-plugin-autostart (already compatible with Tauri v2) to manage this. The toggle state is derived by querying the OS (is_enabled()), not from a file. This ensures the GUI always shows the truth — if a user manually deletes the launch agent, the toggle reflects that.

D5: Log Level Persistence via daemon.toml

The daemon reads its initial log level from daemon.toml at startup (after CLI flags). The GUI is the sole writer of daemon.toml — the set_daemon_setting Tauri command persists the level and sends a SetLogLevel IPC notification to the daemon. The daemon acknowledges the change but does not write daemon.toml itself, avoiding dual-writer race conditions.

Current implementation:

  1. setup_logging() uses a reload::Layer and returns a reload::Handle (infrastructure for future runtime changes)
  2. The handle is not yet wired to EngineManager — runtime filter changes require architectural plumbing (the handle lives in main(), EngineManager is created inside run_daemon_with_config())
  3. The GUI's set_daemon_setting Tauri command writes daemon.toml and sends an IPC SetLogLevel notification
  4. The daemon's SetLogLevel IPC handler validates the level and acknowledges — it does not write daemon.toml
  5. On next startup, the daemon reads the persisted level

Priority: RUST_LOG env var > CLI flags (--verbose/--trace) > daemon.toml > default ("info").

Future work: Wire the reload::Handle into EngineManager (or shared daemon state) to enable runtime log level changes without restart. The reload layer infrastructure is in place.

The GUI's log level default was changed from conductor_gui=debug to conductor_gui=info.

D6: File Format and Schema

preferences.toml:

version = 1

[gui]
minimize_to_tray = true
auto_save_configs = true
check_for_updates = true
event_buffer_size = 1000
midi_learn_timeout = 10
daemon_binary_path = "" # empty = auto-detect

daemon.toml:

version = 1

[logging]
level = "info" # error | warn | info | debug | trace

[analytics]
usage_tracking = false

Both files use version = 1 for forward-compatible schema evolution. A future version bump (e.g., version = 2) can trigger migration logic, following the same pattern as feedback-settings.js legacy key migration.

All fields use #[serde(default)] so that missing fields get sensible defaults. A file with just version = 1 is valid.

D7: Rust Types (in conductor-core)

Place shared types in conductor-core so both the daemon and GUI crates can depend on them without circular dependencies:

// conductor-core/src/config/preferences.rs

use serde::{Deserialize, Serialize};

/// GUI application preferences — persisted to preferences.toml
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuiPreferences {
#[serde(default = "default_version")]
pub version: u8,
#[serde(default)]
pub gui: GuiSettings,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuiSettings {
#[serde(default = "default_true")]
pub minimize_to_tray: bool,
#[serde(default = "default_true")]
pub auto_save_configs: bool,
#[serde(default = "default_true")]
pub check_for_updates: bool,
#[serde(default = "default_event_buffer_size")]
pub event_buffer_size: u32,
#[serde(default = "default_midi_learn_timeout")]
pub midi_learn_timeout: u32,
#[serde(default)]
pub daemon_binary_path: String,
}

/// Daemon operational settings — persisted to daemon.toml
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaemonSettings {
#[serde(default = "default_version")]
pub version: u8,
#[serde(default)]
pub logging: DaemonLogging,
#[serde(default)]
pub analytics: DaemonAnalytics,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaemonLogging {
#[serde(default = "default_log_level")]
pub level: String, // "error" | "warn" | "info" | "debug" | "trace"
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaemonAnalytics {
#[serde(default)]
pub usage_tracking: bool,
}

Use existing default function patterns from config/types.rs (e.g., default_true(), default_brightness()). Add default_event_buffer_size() -> u32 { 1000 }, default_midi_learn_timeout() -> u32 { 10 }, default_log_level() -> String { "info".to_string() }.

Both structs implement Default for zero-config startup.

D8: Tauri Commands

Four new commands registered in conductor-gui/src-tauri/src/main.rs:

CommandInputOutputSide Effects
get_preferencesnoneGuiPreferencesReads ~/.midimon/preferences.toml
save_preferencesGuiPreferences()Atomic write to ~/.midimon/preferences.toml
get_daemon_settingsnoneDaemonSettingsReads ~/.midimon/daemon.toml
set_daemon_settingkey, value()Writes daemon.toml + IPC notify to daemon

Atomic write pattern: Reuse the temp-file → fsync → rename pattern from daemon/state.rs:123-163. Extract this into a shared utility function (e.g., atomic_write_toml()) to avoid duplication.

Error handling: If the file doesn't exist, return Default::default(). If the file exists but is malformed, return an error to the frontend (don't silently use defaults — the user should know their settings file is corrupted).

D9: Frontend Integration

Replace the stubbed saveSettings() and loadSettings() in SettingsPanel.svelte with real Tauri invocations:

async function loadSettings() {
try {
const prefs = await invoke('get_preferences');
settings = {
autoStart: await isAutoStartEnabled(), // platform API query
minimizeToTray: prefs.gui.minimize_to_tray,
autoSaveConfigs: prefs.gui.auto_save_configs,
checkForUpdates: prefs.gui.check_for_updates,
eventBufferSize: prefs.gui.event_buffer_size,
midiLearnTimeout: prefs.gui.midi_learn_timeout,
logLevel: daemonSettings.logging.level, // from get_daemon_settings
};
originalSettings = { ...settings };
} catch (e) {
error = `Failed to load settings: ${e}`;
}
}

async function saveSettings() {
saving = true;
try {
// Save GUI preferences
await invoke('save_preferences', {
preferences: {
version: 1,
gui: {
minimize_to_tray: settings.minimizeToTray,
auto_save_configs: settings.autoSaveConfigs,
check_for_updates: settings.checkForUpdates,
event_buffer_size: settings.eventBufferSize,
midi_learn_timeout: settings.midiLearnTimeout,
}
}
});

// Save daemon settings (log level)
if (settings.logLevel !== originalSettings.logLevel) {
await invoke('set_daemon_setting', {
key: 'logging.level',
value: settings.logLevel
});
}

// Handle autoStart via platform API
if (settings.autoStart !== originalSettings.autoStart) {
await toggleAutoStart(settings.autoStart);
}

originalSettings = { ...settings };
hasChanges = false;
successMessage = 'Settings saved successfully';
} catch (e) {
error = `Failed to save settings: ${e}`;
} finally {
saving = false;
}
}

Remove the redundant hasChanges = false assignment at line 165 (already set at line 181 in the reactive block — this is issue #469).

D10: IPC Protocol Extension

Add a new IPC command for the daemon to acknowledge log level changes:

Request:  { "command": "set_log_level", "args": { "level": "debug" } }
Response: { "status": "ok", "data": { "level": "debug", "message": "Log level acknowledged (takes effect on restart)" } }

The GUI persists the new level to daemon.toml via the set_daemon_setting Tauri command, then sends this IPC notification. The daemon validates the level and acknowledges — it does not write daemon.toml itself (single-writer principle). Runtime log filter updates are not yet implemented (see D5 for future work).


Implementation Phases

Phase 1: Preferences Backend + GUI Wiring

Resolves: #468, #475, #469, #472

  1. Create conductor-core/src/config/preferences.rs with GuiPreferences and DaemonSettings types
  2. Add mod preferences to conductor-core/src/config/mod.rs and re-export from lib.rs
  3. Create conductor-gui/src-tauri/src/preferences.rs with get_preferences and save_preferences commands using atomic write
  4. Register commands in main.rs
  5. Wire SettingsPanel.svelte to use real invoke() calls instead of setTimeout stub
  6. Remove redundant hasChanges assignment (#469)
  7. Fix wrong URLs in About section (#472)
  8. Write tests: Rust unit tests for serialization roundtrip, Svelte component tests for load/save flow

Phase 2: Daemon Settings + Dynamic Log Level

Resolves: #474, #554

  1. Add reload::Layer to daemon's setup_logging() and store the handle
  2. Add set_log_level IPC command to daemon
  3. Create get_daemon_settings and set_daemon_setting Tauri commands
  4. Read daemon.toml at daemon startup to set initial log level
  5. Wire log level dropdown in SettingsPanel to set_daemon_setting
  6. Add usage_tracking field for #554
  7. Write tests: IPC command tests, daemon.toml round-trip

Phase 3: Platform autoStart

Resolves: #475 (autoStart checkbox)

  1. Add tauri-plugin-autostart dependency
  2. Implement is_auto_start_enabled and toggle_auto_start Tauri commands
  3. Wire autoStart toggle in SettingsPanel
  4. Write tests: mock-based tests for the platform API calls

Phase 4: Daemon Binary Path + Tray Integration

Resolves: #506, #552

  1. Surface "Open config file" action in GUI using daemonBinaryPath from preferences
  2. Wire tray menu to use configured daemon binary path instead of hardcoded lookup
  3. Write tests: path resolution tests, tray menu integration tests

Consequences

Positive

  1. User trust restored — Settings actually persist. No more fake success messages.
  2. Clean separation — GUI prefs, daemon ops, and mapping config each have their own file with clear ownership.
  3. No migration burden — New files with #[serde(default)] on all fields. Missing files = fresh defaults. Existing users lose nothing.
  4. Persisted log level — Log level changes are saved to daemon.toml and applied on daemon restart. Runtime reload infrastructure (reload::Handle) is in place for future wiring.
  5. Schema evolutionversion field enables future migrations without breaking existing files.
  6. Reuses proven patterns — Atomic write from state.rs, TOML from config.toml, serde defaults from types.rs.

Negative

  1. Two more files in ~/.midimon/ — Increases the number of files from 4 to 6. Manageable, but adds to the "what are all these files?" question for power users.
  2. Split settings UI — The SettingsPanel now talks to three backends (preferences.toml, daemon IPC, platform API). Error handling for each path must be distinct.
  3. tauri-plugin-autostart dependency — New dependency for Phase 3. Must verify Tauri v2 compatibility and test on all platforms.

Risks

  1. File permission issuesget_state_dir() already validates ownership and sets 0o700 permissions. The new files inherit this protection since they live in the same directory.
  2. Concurrent writes — Each file has a single writer (GUI for preferences.toml, GUI for daemon.toml). The set_daemon_setting Tauri command is the sole writer of daemon.toml, serialized by DAEMON_SETTINGS_LOCK. The daemon only reads daemon.toml at startup — it never writes it.
  3. TOML serialization stability — Field ordering may change between serde_toml versions. Not a functional issue but may cause noisy diffs. Consider using toml_edit if this becomes a problem.

Alternatives Considered

A1: Single settings.toml for Everything

Rejected. The GUI and daemon have different lifecycles and different write patterns. A single file would require file-level locking or accept-last-writer-wins semantics. Two files with clear ownership is simpler and safer.

A2: Store All Settings in config.toml

Rejected. config.toml represents mapping intent. Mixing operational preferences (log level, tray behavior) with device mappings violates single-responsibility and pollutes the Plan/Apply workflow.

A3: Use SQLite for Settings

Rejected. Overkill for ~15 key-value pairs. TOML files are human-readable, diffable, and use existing infrastructure. SQLite would add complexity for settings backup/restore.

A4: Use localStorage for Everything

Rejected. localStorage is not accessible from the Rust backend. Daemon settings (log level) must be readable by the daemon process, which has no access to the WebView's localStorage.

A5: Store Settings in state.json

Rejected. state.json is daemon runtime state (current mode, last reload timestamp). It's written frequently during normal operation. Mixing rarely-changed user preferences with frequently-updated runtime state would cause unnecessary writes and conceptual confusion.


References

  • Epic: #559 (Settings Persistence Architecture)
  • Child Issues: #468, #472, #474, #475, #469, #506, #552, #554
  • Atomic write pattern: conductor-daemon/src/daemon/state.rs:123-163
  • State directory: conductor-daemon/src/daemon/state.rs:204-271
  • Existing settings UI: conductor-gui/ui/src/lib/components/SettingsPanel.svelte
  • localStorage pattern: conductor-gui/ui/src/lib/stores/feedback-settings.js
  • Config types pattern: conductor-core/src/config/types.rs
  • Daemon logging: conductor-daemon/src/main.rs:134-164
  • GUI logging: conductor-gui/src-tauri/src/main.rs:62-68
  • ADR-003: GUI State Management (store patterns)
  • ADR-007: LLM Integration Architecture (Tauri command patterns)