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:
| Mechanism | What It Stores | Where |
|---|---|---|
| localStorage | Theme, accent color, feedback sounds, panel widths | Browser storage (Tauri WebView) |
| System keychain | LLM API keys | OS credential store (tauri-plugin-keychain) |
| config.toml | Device mappings, modes, triggers, actions | Platform config dir* |
| state.json | Daemon runtime state (current mode, last reload) | ~/.midimon/state.json |
| audit.db | MCP tool execution audit log | ~/.midimon/conductor.db |
| conversations.db | Chat 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:
| Setting | Owner | Why |
|---|---|---|
| autoStart | Platform API | OS-level launch agent (macOS) / registry (Windows) / systemd (Linux) |
| minimizeToTray | GUI preference | Only the GUI cares about window behavior |
| autoSaveConfigs | GUI preference | GUI decides when to auto-save |
| checkForUpdates | GUI preference | GUI startup check |
| eventBufferSize | GUI preference | Controls GUI event console buffer |
| midiLearnTimeout | GUI preference | GUI MIDI Learn session timeout |
| daemonBinaryPath | GUI preference | GUI needs to know where to launch daemon |
| logLevel | Daemon operational | Daemon's tracing filter level |
| usageTracking | Daemon operational | Daemon analytics toggle (#554) |
| Theme/accent/feedback/layout | localStorage | Pure CSS/DOM state, already working |
This yields three categories: GUI preferences (file-persisted), daemon settings (file-persisted), and platform API (OS-managed).
Related Issues
| Issue | Title | Relationship |
|---|---|---|
| #559 | Document state persistence boundaries | Epic — this ADR is the deliverable |
| #468 | Settings persistence fully stubbed | Core problem — setTimeout(500) fake save |
| #475 | General settings not wired to backend | All 4 checkboxes decorative |
| #474 | Log level setting has no effect | GUI main.rs:66 hardcodes conductor_gui=debug |
| #472 | About links wrong URLs | SettingsPanel.svelte:678-686 point to wrong org |
| #469 | Redundant hasChanges assignment | Lines 165+181 manual assignments |
| #552 | Can't open config file from GUI | Backend exists but not surfaced |
| #506 | Tray menu ignores daemon binary path | Blocked by #475 |
| #554 | Persist mapping fire usage data | Introduces 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:
- Violate single-responsibility — config.toml has a clear schema validated by
config/validator.rs - Create confusion about what
conductorctl reloadaffects - Pollute config diffs with non-mapping noise
- 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:
.desktopfile 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:
setup_logging()uses areload::Layerand returns areload::Handle(infrastructure for future runtime changes)- The handle is not yet wired to
EngineManager— runtime filter changes require architectural plumbing (the handle lives inmain(), EngineManager is created insiderun_daemon_with_config()) - The GUI's
set_daemon_settingTauri command writesdaemon.tomland sends an IPCSetLogLevelnotification - The daemon's
SetLogLevelIPC handler validates the level and acknowledges — it does not writedaemon.toml - 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:
| Command | Input | Output | Side Effects |
|---|---|---|---|
get_preferences | none | GuiPreferences | Reads ~/.midimon/preferences.toml |
save_preferences | GuiPreferences | () | Atomic write to ~/.midimon/preferences.toml |
get_daemon_settings | none | DaemonSettings | Reads ~/.midimon/daemon.toml |
set_daemon_setting | key, 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
- Create
conductor-core/src/config/preferences.rswithGuiPreferencesandDaemonSettingstypes - Add
mod preferencestoconductor-core/src/config/mod.rsand re-export fromlib.rs - Create
conductor-gui/src-tauri/src/preferences.rswithget_preferencesandsave_preferencescommands using atomic write - Register commands in
main.rs - Wire
SettingsPanel.svelteto use realinvoke()calls instead ofsetTimeoutstub - Remove redundant
hasChangesassignment (#469) - Fix wrong URLs in About section (#472)
- 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
- Add
reload::Layerto daemon'ssetup_logging()and store the handle - Add
set_log_levelIPC command to daemon - Create
get_daemon_settingsandset_daemon_settingTauri commands - Read
daemon.tomlat daemon startup to set initial log level - Wire log level dropdown in SettingsPanel to
set_daemon_setting - Add
usage_trackingfield for #554 - Write tests: IPC command tests, daemon.toml round-trip
Phase 3: Platform autoStart
Resolves: #475 (autoStart checkbox)
- Add
tauri-plugin-autostartdependency - Implement
is_auto_start_enabledandtoggle_auto_startTauri commands - Wire autoStart toggle in SettingsPanel
- Write tests: mock-based tests for the platform API calls
Phase 4: Daemon Binary Path + Tray Integration
Resolves: #506, #552
- Surface "Open config file" action in GUI using
daemonBinaryPathfrom preferences - Wire tray menu to use configured daemon binary path instead of hardcoded lookup
- Write tests: path resolution tests, tray menu integration tests
Consequences
Positive
- User trust restored — Settings actually persist. No more fake success messages.
- Clean separation — GUI prefs, daemon ops, and mapping config each have their own file with clear ownership.
- No migration burden — New files with
#[serde(default)]on all fields. Missing files = fresh defaults. Existing users lose nothing. - Persisted log level — Log level changes are saved to
daemon.tomland applied on daemon restart. Runtime reload infrastructure (reload::Handle) is in place for future wiring. - Schema evolution —
versionfield enables future migrations without breaking existing files. - Reuses proven patterns — Atomic write from
state.rs, TOML fromconfig.toml, serde defaults fromtypes.rs.
Negative
- 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. - Split settings UI — The SettingsPanel now talks to three backends (preferences.toml, daemon IPC, platform API). Error handling for each path must be distinct.
tauri-plugin-autostartdependency — New dependency for Phase 3. Must verify Tauri v2 compatibility and test on all platforms.
Risks
- File permission issues —
get_state_dir()already validates ownership and sets 0o700 permissions. The new files inherit this protection since they live in the same directory. - Concurrent writes — Each file has a single writer (GUI for preferences.toml, GUI for daemon.toml). The
set_daemon_settingTauri command is the sole writer of daemon.toml, serialized byDAEMON_SETTINGS_LOCK. The daemon only reads daemon.toml at startup — it never writes it. - TOML serialization stability — Field ordering may change between serde_toml versions. Not a functional issue but may cause noisy diffs. Consider using
toml_editif 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)