When changing config schemas, treat migrations as part of the API contract: migrations must be safe under partial/legacy states and must not cause read/write skew that overwrites migrated data.
Practical standard:
1) Backfill before cleanup: if legacy keys are being removed while the new structured block exists, first reconstruct any missing new keys needed to preserve user intent (e.g., active_provider), then delete legacy keys.
2) Prevent clobbering across read vs write: ensure the code path that reads values used to construct a write payload sees the same logical (post-migration) state, or explicitly preserve fields that the migration may have created.
3) Use runtime guards for legacy placeholders: if old entries used sentinel values, detect them during lookup/migration and correct behavior without requiring disk rewrites.
4) Split migrations by side effects, but keep behavior consistent: if load() intentionally does not persist provider migrations, then any logic that constructs persisted updates must account for that (e.g., preserve active provider fields after migration).
Example pattern for (2): preserve the migrated model only when updating the active provider; otherwise write a new empty/default model.
let model = if goose::config::get_active_provider(config)
.as_deref()
== Some(provider_name.as_str())
{
config.get_goose_model().unwrap_or_default() // preserved for active provider
} else {
String::new() // new provider
};
goose::config::set_provider_entry(
config,
&provider_name,
&goose::config::ProviderEntry {
enabled: true,
model,
configured: true,
},
)?;
Apply this style whenever a migration changes where authoritative fields live (flat keys → structured blocks, sentinel fixes, etc.).