config-schema-migrator
Skill1 starsChanged 12 months agoMITView on GitHub
Expert at evolving Pydantic configuration schemas with backward compatibility and automated migrations
- Reads credentials
We split this skill into the rules it gives an agent
38 rules. About 0 are common, 23 are distinctive, and 15 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈23
Distinctive · estimate
This author's own choices. Worth a look.
15
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 13
Write migration scripts to automate config updates
- Distinctive: line 15
Add deprecation warnings for old config patterns
- Distinctive: line 17
Create discriminated unions for config type discrimination
- Distinctive: line 21
Backward Compatibility First: Never break existing configs without a migration path
- Distinctive: line 24
Type Safety: Use Pydantic validators to catch config errors at load time
- Very specific: line 70
Use Optional for both old and new sections
- Very specific: line 126
Use Literal["value"] for discriminator field with default value
- Very specific: line 193
Use @field_validator on fields that may contain env vars
- Very specific: line 194
Use info.field_name to customize behavior per field
- Very specific: line 198
Always load .env file first with dotenv.load_dotenv() in load_config()
- Very specific: line 315
Always create backup: before modifying config file (shutil.copy2)
- Distinctive: line 316
Idempotent migrations: Check if already migrated, return early if so
- Very specific: line 321
YAML preservation: Use sort_keys=False to preserve key order
- Very specific: line 389
Use Path(__file__).parent.parent to find project root
- Distinctive: line 391
Keep absolute paths unchanged
- Distinctive: line 451
Keep deprecated field with default value
- Distinctive: line 452
Add "DEPRECATED" to field description
- Distinctive: line 454
Document migration in code comments and CLAUDE.md
- Distinctive: line 526
When you need to evolve a config schema:
- Very specific: line 530
Add new section/fields as Optional (don't break existing configs)
- Distinctive: line 531
Keep old section/fields for backward compatibility
- Very specific: line 532
Add @field_validator for new field validation
- Very specific: line 533
Add deprecation warnings in model_post_init()
- Very specific: line 537
Create scripts/migrate_*.py with clear docstring
- Very specific: line 540
Add main() with CLI argument parsing
- Very specific: line 545
Write unit tests for migrate_config_dict()
- Distinctive: line 546
Test idempotency (running twice is safe)
- Distinctive: line 547
Test edge cases (missing sections, already migrated)
- Distinctive: line 549
Verify migrated config loads successfully
- Distinctive: line 553
Update CLAUDE.md with migration instructions
- Very specific: line 554
Add migration notes to config.yaml comments
- Distinctive: line 556
Update README if needed
- Distinctive: line 560
Commit schema changes + migration script together
- Distinctive: line 563
Keep backward compatibility for at least one major version
- Distinctive: line 616
Never break existing configs: - always provide migration path
- Distinctive: line 617
Automate migrations: - don't force manual editing
- Distinctive: line 619
Support env vars: - never hardcode secrets
- Distinctive: line 626
When you detect config schema evolution needs, activate this skill and follow these patterns to ensure smooth, backward-compatible …
≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
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: 120Key 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 resultYAML Usage:
adapters:
openrouter:
type: http
base_url: "https://openrouter.ai/api/v1"
api_key: "${OPENROUTER_API_KEY}" # Resolved from environmentKey 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 vYAML Usage:
decision_graph:
# OLD (still works, but deprecated in field description)
similarity_threshold: 0.7
# NEW (preferred)
tier_boundaries:
strong: 0.75
moderate: 0.60Key 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.
In this repository6 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- adapter-factorySkill
- convergence-debuggerSkill
- decision-graph-analyzerSkill
- deliberation-testerSkill
- mcp-server-enhancementSkill
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

