Expert at evolving Pydantic configuration schemas with backward compatibility and automated migrations
Installation
Details
Usage
After installing, this skill will be available to your AI coding assistant.
Verify installation:
skills listSkill Instructions
name: "config-schema-migrator" description: "Expert at evolving Pydantic configuration schemas with backward compatibility and automated migrations"
Config Schema Migrator Skill
When to Use This Skill
Activate this skill when you need to:
- Evolve Pydantic configuration schemas (add fields, change types, restructure sections)
- Maintain backward compatibility with existing config files
- Write migration scripts to automate config updates
- Implement environment variable substitution in config fields
- Add deprecation warnings for old config patterns
- Validate config schemas with field validators
- Create discriminated unions for config type discrimination
Key Principles
- Backward Compatibility First: Never break existing configs without a migration path
- Deprecation Before Removal: Warn users before removing old config sections
- Automatic Migration: Provide scripts to automate config updates (don't force manual editing)
- Type Safety: Use Pydantic validators to catch config errors at load time
- Environment Variables: Support ${VAR_NAME} substitution for secrets
- Clear Messaging: Provide helpful error messages with migration instructions
Pattern 1: Adding New Config Sections with Backward Compatibility
Example: Adding adapters Section While Keeping cli_tools
Problem: You need to add a new config section (adapters) to replace an old one (cli_tools) without breaking existing configs.
Solution Pattern (from models/config.py):
from typing import Optional
from pydantic import BaseModel
import warnings
class Config(BaseModel):
"""Root configuration model."""
# New section (preferred)
adapters: Optional[dict[str, AdapterConfig]] = None
# Legacy section (deprecated)
cli_tools: Optional[dict[str, CLIToolConfig]] = None
def model_post_init(self, __context):
"""Post-initialization validation."""
# Ensure at least one section exists
if self.adapters is None and self.cli_tools is None:
raise ValueError(
"Configuration must include either 'adapters' or 'cli_tools' section"
)
# Emit deprecation warning for old section
if self.cli_tools is not None and self.adapters is None:
warnings.warn(
"The 'cli_tools' configuration section is deprecated. "
"Please migrate to 'adapters' section with explicit 'type' field. "
"See migration guide: docs/migration/cli_tools_to_adapters.md",
DeprecationWarning,
stacklevel=2,
)
Key Techniques:
- Use
Optionalfor both old and new sections - Validate in
model_post_init()that at least one exists - Emit
DeprecationWarningwhen old section is used - Reference migration documentation in warning message
- Allow both sections temporarily for gradual migration
Pattern 2: Type Discrimination with Discriminated Unions
Example: CLI vs HTTP Adapters
Problem: You have config objects that can be one of several types (CLI adapter, HTTP adapter, etc).
Solution Pattern (from models/config.py):
from typing import Annotated, Literal, Union
from pydantic import BaseModel, Field
class CLIAdapterConfig(BaseModel):
"""Configuration for CLI-based adapter."""
type: Literal["cli"] = "cli"
command: str
args: list[str]
timeout: int = 60
class HTTPAdapterConfig(BaseModel):
"""Configuration for HTTP-based adapter."""
type: Literal["http"] = "http"
base_url: str
api_key: Optional[str] = None
timeout: int = 60
max_retries: int = 3
# Discriminated union - Pydantic uses 'type' field to determine which model
AdapterConfig = Annotated[
Union[CLIAdapterConfig, HTTPAdapterConfig],
Field(discriminator="type")
]
YAML Usage:
adapters:
claude:
type: cli # Discriminator field
command: "claude"
args: ["-p", "{prompt}"]
timeout: 60
ollama:
type: http # Different type triggers HTTPAdapterConfig
base_url: "http://localhost:11434"
timeout: 120
Key Techniques:
- Use
Literal["value"]for discriminator field with default value - Create
Annotated[Union[...], Field(discriminator="type")] - Pydantic automatically routes to correct model based on
typefield - Each type has different required fields (validated automatically)
Pattern 3: Environment Variable Substitution
Example: API Keys and Secrets
Problem: You need to inject secrets from environment variables without hardcoding in YAML.
Solution Pattern (from models/config.py):
import os
import re
from pydantic import BaseModel, field_validator
class HTTPAdapterConfig(BaseModel):
"""Configuration for HTTP-based adapter."""
base_url: str
api_key: Optional[str] = None
@field_validator("api_key", "base_url")
@classmethod
def resolve_env_vars(cls, v: Optional[str], info) -> Optional[str]:
"""Resolve ${ENV_VAR} references in string fields."""
if v is None:
return v
# Pattern: ${VAR_NAME}
pattern = r"\$\{([^}]+)\}"
is_api_key = info.field_name == "api_key"
def replacer(match):
env_var = match.group(1)
value = os.getenv(env_var)
if value is None:
# For optional fields like api_key, use sentinel
if is_api_key:
return "__MISSING_API_KEY__"
# For required fields, raise error
raise ValueError(
f"Environment variable '{env_var}' is not set. "
f"Required for configuration."
)
return value
result = re.sub(pattern, replacer, v)
# If api_key has sentinel marker, return None (graceful degradation)
if is_api_key and "__MISSING_API_KEY__" in result:
return None
return result
YAML Usage:
adapters:
openrouter:
type: http
base_url: "https://openrouter.ai/api/v1"
api_key: "${OPENROUTER_API_KEY}" # Resolved from environment
Key Techniques:
- Use
@field_validatoron fields that may contain env vars - Use
info.field_nameto customize behavior per field - Regex pattern
r"\$\{([^}]+)\}"to find${VAR_NAME} - For optional fields (api_key): return
Noneif env var missing - For required fields (base_url): raise
ValueErrorif env var missing - Always load
.envfile first withdotenv.load_dotenv()inload_config()
Pattern 4: Writing Migration Scripts
Example: CLI Tools to Adapters Migration
Problem: You need to migrate existing config files from old format to new format automatically.
Solution Pattern (from scripts/migrate_config.py):
#!/usr/bin/env python3
"""
Migration script: cli_tools → adapters
Migrates config.yaml from legacy cli_tools format to new adapters format
with explicit type fields.
Usage:
python scripts/migrate_config.py [path/to/config.yaml]
"""
import shutil
import sys
from pathlib import Path
from typing import Any, Dict
import yaml
def migrate_config_dict(config: Dict[str, Any]) -> Dict[str, Any]:
"""
Migrate config dictionary from cli_tools to adapters format.
Returns:
Migrated config with adapters section
"""
# If already migrated, return as-is
if "adapters" in config and "cli_tools" not in config:
print("Info: Config already migrated (has 'adapters' section)")
return config
# If no cli_tools, nothing to migrate
if "cli_tools" not in config:
print("Warning: No 'cli_tools' section found, nothing to migrate")
return config
# Create new config with adapters
migrated = config.copy()
# Transform cli_tools → adapters
adapters = {}
for name, cli_config in config["cli_tools"].items():
adapters[name] = {
"type": "cli", # Add explicit type discriminator
"command": cli_config["command"],
"args": cli_config["args"],
"timeout": cli_config["timeout"],
}
migrated["adapters"] = adapters
del migrated["cli_tools"]
print(f"Success: Migrated {len(adapters)} CLI tools to adapters format")
return migrated
def migrate_config_file(path: str) -> None:
"""
Migrate config file from cli_tools to adapters format.
Creates a backup at {path}.bak before modifying.
"""
config_path = Path(path)
if not config_path.exists():
raise FileNotFoundError(f"Config file not found: {path}")
# Create backup BEFORE modifying
backup_path = Path(f"{path}.bak")
shutil.copy2(config_path, backup_path)
print(f"Created backup: {backup_path}")
# Load config
with open(config_path, "r") as f:
config = yaml.safe_load(f)
# Migrate
migrated = migrate_config_dict(config)
# Write migrated config
with open(config_path, "w") as f:
yaml.dump(migrated, f, default_flow_style=False, sort_keys=False)
print(f"Migrated config written to: {config_path}")
print(f"\nInfo: Review the changes and delete {backup_path} when satisfied.")
def main():
"""Main entry point."""
config_path = sys.argv[1] if len(sys.argv) > 1 else "config.yaml"
print(f"Migrating config: {config_path}")
print("-" * 50)
try:
migrate_config_file(config_path)
print("\nMigration complete!")
print("\nNext steps:")
print("1. Review the migrated config.yaml")
print("2. Test loading: python -c 'from models.config import load_config; load_config()'")
print("3. Delete backup if satisfied: rm config.yaml.bak")
except Exception as e:
print(f"\nError: Migration failed: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
Key Techniques:
- Always create backup before modifying config file (
shutil.copy2) - Idempotent migrations: Check if already migrated, return early if so
- Separate dict and file logic:
migrate_config_dict()for logic,migrate_config_file()for I/O - Clear console output: Print status messages for user feedback
- Testing instructions: Print validation commands after migration
- Error handling: Catch exceptions, print helpful message, exit with code 1
- YAML preservation: Use
sort_keys=Falseto preserve key order
Pattern 5: Field Validation for Path Resolution
Example: Database Path with Environment Variables
Problem: You need to resolve relative paths and environment variables for config fields.
Solution Pattern (from models/config.py):
import os
import re
from pathlib import Path
from pydantic import BaseModel, field_validator
class DecisionGraphConfig(BaseModel):
"""Configuration for decision graph memory."""
db_path: str = "decision_graph.db"
@field_validator("db_path")
@classmethod
def resolve_db_path(cls, v: str) -> str:
"""
Resolve db_path to absolute path relative to project root.
Processing steps:
1. Resolve ${ENV_VAR} environment variable references
2. Convert relative paths to absolute paths relative to project root
3. Keep absolute paths unchanged
4. Return normalized absolute path as string
Examples:
"decision_graph.db" → "/path/to/project/decision_graph.db"
"/tmp/foo.db" → "/tmp/foo.db" (unchanged)
"${DATA_DIR}/graph.db" → "/var/data/graph.db" (if DATA_DIR=/var/data)
"""
# Step 1: Resolve environment variables
pattern = r"\$\{([^}]+)\}"
def replacer(match):
env_var = match.group(1)
value = os.getenv(env_var)
if value is None:
raise ValueError(
f"Environment variable '{env_var}' is not set. "
f"Required for db_path configuration."
)
return value
resolved = re.sub(pattern, replacer, v)
# Step 2: Convert to Path object
path = Path(resolved)
# Step 3: If relative, make it relative to project root
if not path.is_absolute():
# This file is at: project_root/models/config.py
# Project root is two levels up from this file
project_root = Path(__file__).parent.parent
path = (project_root / path).resolve()
# Step 4: Return as string (normalized, absolute)
return str(path)
Key Techniques:
- Resolve env vars BEFORE path resolution
- Use
Path(__file__).parent.parentto find project root - Convert relative paths to absolute (prevents CWD issues)
- Keep absolute paths unchanged
- Return as string for serialization compatibility
Pattern 6: Deprecating Fields with Validation
Example: Deprecating similarity_threshold in Favor of tier_boundaries
Problem: You need to replace a single config field with a more complex structure.
Solution Pattern (from models/config.py):
from pydantic import BaseModel, Field, field_validator
class DecisionGraphConfig(BaseModel):
"""Configuration for decision graph memory."""
# DEPRECATED field (kept for backward compatibility)
similarity_threshold: float = Field(
0.7,
ge=0.0,
le=1.0,
description="DEPRECATED: Use tier_boundaries instead.",
)
# NEW field (preferred)
tier_boundaries: dict[str, float] = Field(
default_factory=lambda: {"strong": 0.75, "moderate": 0.60},
description="Similarity score boundaries for tiered injection"
)
@field_validator("tier_boundaries")
@classmethod
def validate_tier_boundaries(cls, v: dict[str, float]) -> dict[str, float]:
"""Validate tier boundaries: strong > moderate > 0."""
if not isinstance(v, dict) or "strong" not in v or "moderate" not in v:
raise ValueError("tier_boundaries must have 'strong' and 'moderate' keys")
if not (0.0 < v["moderate"] < v["strong"] <= 1.0):
raise ValueError(
f"tier_boundaries must satisfy: 0 < moderate ({v['moderate']}) "
f"< strong ({v['strong']}) <= 1"
)
return v
YAML Usage:
decision_graph:
# OLD (still works, but deprecated in field description)
similarity_threshold: 0.7
# NEW (preferred)
tier_boundaries:
strong: 0.75
moderate: 0.60
Key Techniques:
- Keep deprecated field with default value
- Add "DEPRECATED" to field description
- Validate new field structure with
@field_validator - Document migration in code comments and CLAUDE.md
- Eventually remove deprecated field in future major version
Testing Migration Scripts
Before Deployment Checklist
-
Unit Test the Migration Logic:
def test_migrate_config_dict(): """Test migration transforms cli_tools to adapters.""" old_config = { "cli_tools": { "claude": { "command": "claude", "args": ["-p", "{prompt}"], "timeout": 60 } } } migrated = migrate_config_dict(old_config) assert "adapters" in migrated assert "cli_tools" not in migrated assert migrated["adapters"]["claude"]["type"] == "cli" assert migrated["adapters"]["claude"]["command"] == "claude" -
Test Idempotency:
def test_migrate_idempotent(): """Test migrating already-migrated config is safe.""" already_migrated = { "adapters": { "claude": {"type": "cli", "command": "claude"} } } result = migrate_config_dict(already_migrated) assert result == already_migrated # No changes -
Manual Testing Steps:
# 1. Create test config cp config.yaml config.test.yaml # 2. Run migration python scripts/migrate_config.py config.test.yaml # 3. Verify backup created ls -la config.test.yaml.bak # 4. Test loading migrated config python -c "from models.config import load_config; c = load_config('config.test.yaml'); print('OK')" # 5. Compare files diff config.test.yaml.bak config.test.yaml # 6. Clean up rm config.test.yaml config.test.yaml.bak -
Load-Time Validation:
# After migration, always test that config loads without errors python -c "from models.config import load_config; load_config()"
Complete Migration Workflow
When you need to evolve a config schema:
Step 1: Update Pydantic Models
- Add new section/fields as
Optional(don't break existing configs) - Keep old section/fields for backward compatibility
- Add
@field_validatorfor new field validation - Add deprecation warnings in
model_post_init()
Step 2: Write Migration Script
- Create
scripts/migrate_*.pywith clear docstring - Implement
migrate_config_dict()for logic (testable) - Implement
migrate_config_file()for I/O (backup, load, migrate, save) - Add
main()with CLI argument parsing - Print clear instructions after migration
Step 3: Test Migration
- Write unit tests for
migrate_config_dict() - Test idempotency (running twice is safe)
- Test edge cases (missing sections, already migrated)
- Manually test on real config file
- Verify migrated config loads successfully
Step 4: Document Migration
- Update CLAUDE.md with migration instructions
- Add migration notes to config.yaml comments
- Reference migration script in deprecation warnings
- Update README if needed
Step 5: Deploy
- Commit schema changes + migration script together
- Announce deprecation to users
- Provide migration timeline (e.g., "deprecated in v2.0, removed in v3.0")
- Keep backward compatibility for at least one major version
Real-World Example: The cli_tools → adapters Migration
Context: AI Counsel needed to support both CLI and HTTP adapters, requiring type discrimination.
Changes Made:
-
Schema Evolution (
models/config.py):- Created
CLIAdapterConfigandHTTPAdapterConfigwithtypediscriminator - Made
adaptersandcli_toolsboth optional - Added validation that at least one exists
- Added deprecation warning for
cli_tools
- Created
-
Migration Script (
scripts/migrate_config.py):- Transforms
cli_tools→adapterswithtype: "cli" - Creates backup before modifying
- Idempotent (safe to run multiple times)
- Clear user feedback and next steps
- Transforms
-
Testing:
- Unit tests for
migrate_config_dict() - Integration tests for file I/O
- Manual testing on production config
- Unit tests for
-
Documentation:
- Updated CLAUDE.md with migration guide
- Added comments to config.yaml explaining both formats
- Referenced migration script in deprecation warning
Result: Users can migrate seamlessly with one command, and old configs continue working with a warning.
Common Patterns Summary
| Pattern | Use Case | Key Technique |
|---|---|---|
| Optional Sections | Add new section while keeping old | Optional[T] + validation |
| Discriminated Union | Type discrimination (CLI vs HTTP) | Literal["type"] + Field(discriminator="type") |
| Env Var Substitution | Inject secrets from environment | @field_validator + regex \$\{VAR\} |
| Path Resolution | Resolve relative paths | Path(__file__).parent + resolve() |
| Deprecation Warnings | Signal old patterns | warnings.warn() in model_post_init() |
| Migration Scripts | Automate config updates | Backup + dict transform + YAML dump |
| Field Validation | Complex field constraints | @field_validator + custom logic |
File References
- Config Models:
/Users/harrison/Github/ai-counsel/models/config.py - Migration Script:
/Users/harrison/Github/ai-counsel/scripts/migrate_config.py - Config File:
/Users/harrison/Github/ai-counsel/config.yaml - Project Docs:
/Users/harrison/Github/ai-counsel/CLAUDE.md
Key Takeaways
- Never break existing configs - always provide migration path
- Automate migrations - don't force manual editing
- Use Pydantic validators - catch errors at load time
- Support env vars - never hardcode secrets
- Test thoroughly - unit + integration + manual testing
- Document clearly - in code, CLAUDE.md, and warnings
- Version carefully - deprecate → warn → remove (over multiple versions)
When you detect config schema evolution needs, activate this skill and follow these patterns to ensure smooth, backward-compatible migrations.
More by blueman82
View allGuide for creating new CLI or HTTP adapters to integrate AI models into the AI Counsel deliberation system
Query and analyze the AI Counsel decision graph to find past deliberations, identify patterns, and debug memory issues
Guide for safely adding new MCP tools to the AI Counsel server
Expert debugging guide for AI Counsel's convergence detection system. Use when deliberations aren't converging as expected, early stopping fails, semantic similarity seems wrong, or voting vs semantic status conflicts occur.