Database Migration System — Implementation Plan¶
Summary¶
This plan implements the database migration system designed in docs/designs/v3.1/02-database-migrations.md. MAID currently has no migration system — schema is created inline via CREATE TABLE IF NOT EXISTS in DatabaseManager.initialize() and PostgresDocumentStore.initialize(), and the maid db CLI commands are stubs. This implementation introduces:
- A custom migration runner built on asyncpg (no SQLAlchemy dependency for migrations)
- Multi-namespace content pack migration support with dependency resolution
- Safe JSONB document evolution via a structured
JSONBUpdateSpecbuilder - Batched operations with checkpoint-based resume for large datasets
- Global advisory lock for migration serialization
- Two-tier security model (trusted first-party Python / untrusted community YAML)
- Production-ready CLI commands replacing the current stubs
- Explicit baseline path for existing database deployments
Existing code affected:
- packages/maid-engine/src/maid_engine/storage/database.py — DatabaseManager gains from_pool() constructor
- packages/maid-engine/src/maid_engine/storage/document_store.py — PostgresDocumentStore gains from_pool() constructor; documents table gains doc_seq column via dedicated migration
- packages/maid-engine/src/maid_engine/cli/app.py — db_migrate stub replaced with real implementation
- packages/maid-engine/src/maid_engine/plugins/manifest.py — ContentPackManifest extended with migration_requires field
- packages/maid-engine/src/maid_engine/config/settings.py — DatabaseSettings gains dsn property (delegates to existing url); new MigrationSettings added
New module created:
- packages/maid-engine/src/maid_engine/migrations/ — migration framework (10 modules, down from 15 after simplifications)
Phase 1: Core Types, Protocol, and Exceptions (P0)¶
1.1 Migration Exception Hierarchy¶
Package:
maid-engine| Priority: P0 | Dependencies: none
- [ ] Create
packages/maid-engine/src/maid_engine/migrations/__init__.py - [ ] Module docstring explaining the migration framework
- [ ] Re-export key public types:
Migration,YAMLMigration,ExecutionMode,BaseMigrationContext,MigrationContext - [ ] Create
packages/maid-engine/src/maid_engine/migrations/exceptions.py - [ ]
MigrationError(Exception)— base exception for all migration errors - [ ]
MigrationLoadError(MigrationError)— failed to load/parse migration file - [ ]
MigrationExecutionError(MigrationError)— migration upgrade/downgrade failed - [ ]
MigrationDependencyError(MigrationError)— unmet dependency or circular dependency - [ ]
MigrationLockTimeout(MigrationError)— advisory lock acquisition timed out - [ ]
MigrationChecksumError(MigrationError)— applied migration checksum mismatch - [ ]
OwnershipViolationError(MigrationError)— namespace tried to modify another namespace's component (deferred — not enforced in V1) - [ ]
SecurityError(MigrationError)— trust level violation (e.g., community pack using Python) - [ ]
CheckpointError(MigrationError)— checkpoint creation/resume failure - [ ] All exceptions should include
namespaceandsequencefields where applicable
1.2 Core Data Types¶
Package:
maid-engine| Priority: P0 | Dependencies: none
- [ ] Create
packages/maid-engine/src/maid_engine/migrations/types.py - [ ]
MigrationSourcedataclass:namespace: str,directory: Path,dependencies: list[str],migration_requires: dict[str, int] | None- [ ] Note:
dependenciesis derived frommanifest.dependencies.keys()at construction time, keepingContentPackManifest.dependencies: dict[str, str]untouched
- [ ] Note:
- [ ]
MigrationPlandataclass:migrations: list[Migration],namespaces: set[str],is_dry_run: bool - [ ]
MigrationResultdataclass:success: bool,applied: list[tuple[str, int]],errors: list[str],duration_seconds: float,dry_run: bool - [ ]
RollbackPlandataclass:migrations: list[AppliedMigration],errors: list[str],destructive: list[AppliedMigration],requires_force: bool - [ ]
AppliedMigrationdataclass:namespace: str,sequence: int,name: str,checksum: str,status: str,applied_at: datetime,execution_ms: int | None,applied_by: str | None - [ ]
ChecksumMismatchdataclass:namespace: str,sequence: int,stored: str,current: str
1.3 Migration Protocol and Context Hierarchy¶
Package:
maid-engine| Priority: P0 | Dependencies: 1.1
- [ ] Create
packages/maid-engine/src/maid_engine/migrations/protocol.py - [ ]
ExecutionModeenum with values:TRANSACTIONAL,BATCHED,NO_TRANSACTION - [ ]
validate_execution_modes(execution_mode, rollback_execution_mode)function — raisesValueErroron incompatible combos (BATCHED↔NO_TRANSACTION) - [ ]
Migrationprotocol (runtime_checkable):- [ ] Required attributes:
namespace: str,sequence: int,description: str - [ ] Optional attributes with defaults:
ONLINE_SAFE: bool,execution_mode: ExecutionMode,rollback_execution_mode: ExecutionMode,ROLLBACK_IDEMPOTENT: bool,DESTRUCTIVE_ROLLBACK: bool,STATEMENT_TIMEOUT_MS: int | None,LOCK_TIMEOUT_MS: int | None,MIGRATION_TIMEOUT_S: int | None - [ ]
async def upgrade(self, ctx: BaseMigrationContext) -> None - [ ]
async def downgrade(self, ctx: BaseMigrationContext) -> None - [ ]
def get_checksum(self) -> str
- [ ] Required attributes:
- [ ]
RestrictedMigrationprotocol — REMOVED (trust tiers collapsed to TRUSTED/UNTRUSTED; all first-party packs use fullMigrationContext) - [ ]
YAMLMigrationdataclass implementingMigrationprotocol — purpose-built for YAML-loaded migrations:- [ ] All protocol fields with defaults
- [ ]
_upgrade_fn,_downgrade_fn,_source_pathprivate fields - [ ]
__post_init__callingvalidate_execution_modes - [ ]
get_checksum()using SHA-256 of source file or fallback tonamespace:sequence - [ ] Note: Tests can create ad-hoc migration classes directly (Python makes this trivial);
MigrationDescriptorgeneral-purpose class removed
- [ ]
BaseMigrationContextdataclass:- [ ] Fields:
namespace,batch_executor,checkpoint_manager,logger,dry_run
- [ ] Fields:
- [ ]
MigrationContext(BaseMigrationContext)dataclass:- [ ] Added field:
conn: asyncpg.Connection - [ ]
async def execute(self, query, *args)helper - [ ]
async def fetch(self, query, *args)helper
- [ ] Added field:
- [ ] REMOVED:
RestrictedContext— all first-party packs are maintained by the same team; runtime restriction is premature - [ ] Write unit tests at
packages/maid-engine/tests/migrations/test_protocol.py: - [ ] Test
validate_execution_modeswith valid and invalid combos - [ ] Test
YAMLMigration.get_checksum()with real file and fallback - [ ] Test
YAMLMigration.__post_init__rejects invalid mode combos - [ ] Test
Migrationprotocol runtime checking withisinstance()
1.4 Security Model — Trust Levels¶
Package:
maid-engine| Priority: P0 | Dependencies: 1.1
- [ ] Implement trust validation within
packages/maid-engine/src/maid_engine/migrations/protocol.py(merged from separatesecurity.py): - [ ]
MigrationTrustLevelenum:TRUSTED,UNTRUSTED(collapsed from three tiers — all first-party packs are TRUSTED) - [ ]
TRUSTED_NAMESPACES = frozenset({"engine", "stdlib", "classic-rpg", "tutorial-world"}) - [ ]
get_trust_level(namespace: str) -> MigrationTrustLevelfunction — known namespaces are TRUSTED, all others UNTRUSTED - [ ]
validate_migration_format(namespace: str, migration_path: Path) -> None— raisesSecurityErrorif untrusted pack uses.py - [ ]
validate_execution_mode_trust(namespace, execution_mode, rollback_execution_mode)— restricts untrusted toTRANSACTIONALonly - [ ] Write unit tests at
packages/maid-engine/tests/migrations/test_protocol.py(alongside protocol tests): - [ ] Test trust level assignment (first-party → TRUSTED, unknown → UNTRUSTED)
- [ ] Test
validate_migration_formatrejects.pyfor untrusted - [ ] Test
validate_execution_mode_trustenforces restrictions per tier
Phase 2: Infrastructure Tables and History Tracking (P0)¶
2.1 Migration History Manager¶
Package:
maid-engine| Priority: P0 | Dependencies: 1.2
- [ ] Create
packages/maid-engine/src/maid_engine/migrations/history.py - [ ]
MigrationHistoryclass:- [ ]
__init__(self, pool: asyncpg.Pool) - [ ]
async def ensure_infrastructure(self, pool: asyncpg.Pool)—CREATE TABLE IF NOT EXISTSfor: - [ ]
_migration_historytable (PK: namespace, sequence; fields: name, checksum, status, applied_at, execution_ms, applied_by) - [ ]
_migration_rollback_logtable (id SERIAL PK, namespace, sequence, rolled_back_at, rolled_back_by, reason) - [ ]
_migration_checkpointstable (checkpoint_id VARCHAR(64) PK, processed_count, last_seen_cursor BIGINT, total_expected, started_at, updated_at) - [ ] All tables created in a single transaction
- [ ] Note:
_migration_lock_heartbeattable removed — PostgreSQL advisory locks release on session close; useidle_in_transaction_session_timeoutfor zombie connections - [ ]
async def get_all_applied(self) -> dict[str, int]— returns{namespace: max_sequence} - [ ]
async def get_applied_migrations(self, namespace, limit) -> list[AppliedMigration] - [ ]
async def get_latest(self, namespace) -> AppliedMigration | None - [ ]
async def get_namespaces(self) -> list[str] - [ ]
async def record_applied(self, conn, migration, status="applied")— INSERT into history - [ ]
async def update_status(self, conn, migration, status)— UPDATE status in history - [ ]
async def verify_checksums(self, known_migrations)— compare stored vs current, returnlist[ChecksumMismatch] - [ ]
async def delete_applied(self, conn, namespace, sequence)— DELETE from history - [ ]
async def record_rollback(self, conn, namespace, sequence, rolled_back_by, reason)— INSERT into rollback log
- [ ]
- [ ] Write unit tests at
packages/maid-engine/tests/migrations/test_history.py: - [ ] Test
ensure_infrastructurecreates tables (requires test database or mock) - [ ] Test
record_appliedandget_all_appliedroundtrip - [ ] Test
verify_checksumsdetects mismatches - [ ] Test
get_applied_migrationsreturns in reverse order - [ ] Test
update_statustransitions (pending → applied, applied → rollback_started)
2.2 Checkpoint Manager¶
Package:
maid-engine| Priority: P0 | Dependencies: 2.1
- [ ] Create
packages/maid-engine/src/maid_engine/migrations/checkpoint.py - [ ]
CheckpointManagerclass:- [ ]
@staticmethod async def get_or_create(pool, checkpoint_id) -> Checkpoint— returns existing or creates new checkpoint - [ ]
@staticmethod async def update_progress(conn, checkpoint_id, processed_count, last_seen_cursor)— UPDATE within caller's transaction - [ ]
@staticmethod async def complete(pool, checkpoint_id)— DELETE completed checkpoint - [ ]
@staticmethod async def get(pool, checkpoint_id) -> Checkpoint | None— look up by ID
- [ ]
- [ ]
Checkpointdataclass:checkpoint_id: str,processed_count: int,last_seen_cursor: int | None,total_expected: int | None,started_at: datetime,updated_at: datetime - [ ] Write unit tests at
packages/maid-engine/tests/migrations/test_checkpoint.py: - [ ] Test create, get, update, complete lifecycle
- [ ] Test
get_or_createreturns existing when present - [ ] Test
completeremoves checkpoint row
2.3 Database Settings Enhancement¶
Package:
maid-engine| Priority: P0 | Dependencies: none
- [ ] Modify
packages/maid-engine/src/maid_engine/config/settings.py: - [ ] Add
dsnproperty toDatabaseSettings— delegates to existingurlproperty: - [ ] Add
MigrationSettingsclass:- [ ]
enabled_packs: list[str](default:["engine", "stdlib", "classic-rpg", "tutorial-world"]) - [ ]
default_statement_timeout_ms: int(default:30000) - [ ]
default_lock_timeout_ms: int(default:5000) - [ ]
default_migration_timeout_s: int(default:3600) - [ ]
default_batch_size: int(default:5000) - [ ]
lock_acquire_timeout_s: int(default:30)
- [ ]
- [ ] Add
migration: MigrationSettingstoSettingsclass - [ ] Add
"migration"toSettingsProxy._SECTION_NAMES - [ ] Modify
packages/maid-engine/src/maid_engine/storage/database.py: - [ ] Add
@classmethod from_pool(cls, pool: asyncpg.Pool) -> DatabaseManager— setsself._pool = pool,self._owns_pool = False - [ ] Add
_owns_pool: boolflag (defaultTruefor existing constructor) - [ ] Guard
close()to only close pool when_owns_pool is True - [ ] Modify
packages/maid-engine/src/maid_engine/storage/document_store.py: - [ ] Add
@classmethod from_pool(cls, pool: asyncpg.Pool, table_name="documents") -> PostgresDocumentStore— setsself._pool = pool,self._owns_pool = False - [ ] Add
_owns_pool: boolflag, guardclose()similarly - [ ] Write tests for
DatabaseSettings.dsnproperty - [ ] Write tests for
from_pool()constructors on both classes
Phase 3: Advisory Lock (P0)¶
3.1 Global Migration Advisory Lock¶
Package:
maid-engine| Priority: P0 | Dependencies: 2.1
- [ ] Create
packages/maid-engine/src/maid_engine/migrations/lock.py - [ ]
MigrationAdvisoryLockclass (global lock, not per-namespace — CLI migrations run infrequently; per-namespace parallelism adds complexity and race conditions without meaningful benefit):- [ ]
LOCK_ID = 0x4D414944(hex for 'MAID') — single global lock ID - [ ]
__init__(self, pool: asyncpg.Pool) - [ ]
@asynccontextmanager async def acquire(self, timeout_seconds=30): - [ ] Acquire dedicated lock connection from pool
- [ ]
pg_try_advisory_lock($1)in retry loop with exponential backoff (0.5s → 8s cap) - [ ] Raise
MigrationLockTimeouton deadline - [ ]
pg_advisory_unlockand release connection infinally - [ ] No heartbeat mechanism — PostgreSQL advisory locks release on session close; for zombie connections, configure
idle_in_transaction_session_timeoutat the PostgreSQL level
- [ ]
- [ ] Write unit tests at
packages/maid-engine/tests/migrations/test_lock.py: - [ ] Test lock acquire/release lifecycle (requires test database or mock)
- [ ] Test timeout raises
MigrationLockTimeout - [ ] Test concurrent acquire blocks second caller
Phase 4: JSONB Transform Library (P0)¶
4.1 JSONBUpdateSpec Builder¶
Package:
maid-engine| Priority: P0 | Dependencies: 1.3
- [ ] Create
packages/maid-engine/src/maid_engine/migrations/jsonb_transforms.py - [ ]
TransformFunctionenum:TO_INTEGER,TO_FLOAT,TO_STRING,TO_BOOLEAN,TO_ARRAY_WRAP,EPOCH_TO_ISO,NULLIFY - [ ]
JSONBUpdateSpecfrozen dataclass implementingBatchOperationprotocol:- [ ] Fields:
collection,component,operation(Literal["rename", "add", "remove", "transform"]),field_name,new_field_name,default_value,transform_fn - [ ]
@property operation_key— stable identifier for checkpoint hashing - [ ]
@property table— returns"documents" - [ ]
@property cursor_column— returns"doc_seq" - [ ]
to_where_sql(param_offset) -> tuple[str, list[Any]]— parameterized WHERE - [ ]
to_update_sql(param_offset) -> tuple[str, list[Any]]— parameterized UPDATE SET
- [ ] Fields:
- [ ]
_is_valid_identifier(name: str) -> bool— regex^[a-zA-Z_][a-zA-Z0-9_]*$ - [ ] Public helper functions (all validate identifiers via
_is_valid_identifier):- [ ]
async def rename_jsonb_field(ctx, *, collection, component, old_name, new_name, batch_size=5000) -> int - [ ]
async def add_jsonb_field(ctx, *, collection, component, field_name, default_value, batch_size=5000) -> int - [ ]
async def remove_jsonb_field(ctx, *, collection, component, field_name, batch_size=5000) -> int - [ ]
async def transform_jsonb_field(ctx, *, collection, component, field_name, transform_fn, batch_size=5000) -> int - [ ]
async def set_schema_version(ctx, *, collection, version, component_filter=None) -> int
- [ ]
- [ ] All helper functions validate identifiers with
_is_valid_identifierbefore proceeding - [ ] Write unit tests at
packages/maid-engine/tests/migrations/test_jsonb_transforms.py: - [ ] Test
JSONBUpdateSpec.to_where_sqlgenerates parameterized SQL - [ ] Test
JSONBUpdateSpec.to_update_sqlfor each operation type (rename, add, remove, transform) - [ ] Test
_is_valid_identifieraccepts/rejects correctly - [ ] Test helper functions execute without ownership checks (ownership enforcement deferred)
4.2 Component Ownership Validator — REMOVED¶
Rationale: Trust tiers collapsed to TRUSTED/UNTRUSTED. All first-party packs are maintained by the same team; runtime ownership enforcement is premature. The
componentsmanifest field is also removed. If cross-namespace ownership enforcement is needed later, add it then.
- [ ] Remove
ComponentOwnershipValidatorfrom the plan - [ ] Remove ownership validation calls from JSONB helper functions (the helpers still validate identifiers via
_is_valid_identifier) - [ ] Remove
OwnershipViolationErrorfrom active use (keep exception class for forward compatibility)
4.3 BatchExecutor¶
Package:
maid-engine| Priority: P0 | Dependencies: 2.2, 4.1
- [ ] Create
packages/maid-engine/src/maid_engine/migrations/batch_executor.py - [ ]
BatchOperationprotocol (runtime_checkable):- [ ]
to_where_sql(param_offset) -> tuple[str, list[Any]] - [ ]
to_update_sql(param_offset) -> tuple[str, list[Any]] - [ ]
@property table -> str - [ ]
@property collection -> str | None - [ ]
@property cursor_column -> str - [ ]
@property operation_key -> str
- [ ]
- [ ]
BatchExecutorclass:- [ ]
__init__(self, pool: asyncpg.Pool) - [ ] Implementation note: The parameter indexing logic in
execute()(dynamic$Nplaceholders across CTE + UPDATE) is complex enough to have subtle bugs. Prototype with a real PostgreSQL instance and 100+ rows before finalizing. Write at least one integration test early. - [ ]
async def execute(self, spec: BatchOperation, *, batch_size=5000, namespace="", sequence=0) -> int: - [ ] Compute deterministic checkpoint ID via
_deterministic_checkpoint_id - [ ] Resume from existing checkpoint (
CheckpointManager.get_or_create) - [ ] Loop: keyset pagination (
WHERE cursor_col > $last ORDER BY cursor_col LIMIT $batch) - [ ] Each batch: atomic mini-transaction wrapping data update + checkpoint update
- [ ]
asyncio.sleep(0.1)between batches - [ ]
CheckpointManager.completeon finish - [ ] Return total rows updated
- [ ]
@staticmethod _deterministic_checkpoint_id(spec, namespace, sequence) -> str— SHA-256 ofnamespace:sequence:operation_key, truncated to 16 chars
- [ ]
- [ ] Write unit tests at
packages/maid-engine/tests/migrations/test_batch_executor.py: - [ ] Test
_deterministic_checkpoint_idis stable across calls - [ ] Test
_deterministic_checkpoint_iddiffers by namespace/sequence - [ ] Test
executeprocesses all rows (with mock or test DB) - [ ] Test
executeresumes from checkpoint - [ ] Test each batch commits atomically with checkpoint
Phase 5: Content Pack Discovery and Planning (P0)¶
5.1 Content Pack Manifest Extension¶
Package:
maid-engine| Priority: P0 | Dependencies: none
- [ ] Modify
packages/maid-engine/src/maid_engine/plugins/manifest.py: - [ ] Add field to
ContentPackManifest:- [ ]
migration_requires: dict[str, int] = field(default_factory=dict)— e.g.,{"engine": 2} - [ ] Note:
componentsfield removed — ownership enforcement deferred (see Phase 4.2)
- [ ]
- [ ] Update
from_dictandto_dictto handlemigration_requires - [ ] Derive migration metadata from existing
pack.manifestproperty — do NOT add separate__manifest__dicts to__init__.pyfiles (avoids duplicate sources of truth and drift risk with existing pack loader) - [ ] Note:
enginenamespace is treated as a built-in migration source (not a content pack); its migrations directory is hardcoded in the discovery module sincemaid-engineis the host, not a traditional content pack - [ ] Write tests verifying
ContentPackManifesthandlesmigration_requiresinfrom_dict/to_dict
5.2 Pack Manifest Registry (Discovery)¶
Package:
maid-engine| Priority: P0 | Dependencies: 5.1, 1.4
- [ ] Create
packages/maid-engine/src/maid_engine/migrations/discovery.py - [ ]
PackManifestRegistryclass:- [ ]
__init__(self)— initializes_manifests: dict[str, ContentPackManifest] - [ ]
RESERVED_NAMESPACES = frozenset({"engine", "stdlib", "classic-rpg", "tutorial-world", "_internal", "maid", "core", "system"}) - [ ]
async def discover_available_packs(self) -> dict[str, ContentPackManifest]: - [ ] Scan
importlib.metadata.entry_points(group="maid.content_packs") - [ ] Load pack module, instantiate, read
manifestproperty (reuses existing pack metadata path) - [ ] Hardcode
engineas a built-in source (not a content pack — it's the host) - [ ] Log warnings for failed loads
- [ ]
async def discover_migration_sources(self, enabled_packs=None) -> list[MigrationSource]: - [ ] Default
enabled_packsto first-party only (not all discovered) - [ ] Resolve load order via
_resolve_dependency_order - [ ] Call
_find_migration_directoryfor each - [ ]
def _find_migration_directory(self, pack_name) -> Path | None: - [ ] Primary:
entry_points(group="maid.migrations") - [ ] Fallback:
importlib.util.find_spec(f"maid_{pack_name.replace('-', '_')}"); look formigrations/subdir - [ ]
def validate_namespace(self, namespace)— prevent community packs from using reserved namespaces - [ ]
def get_manifest(self, namespace) -> ContentPackManifest | None
- [ ]
- [ ]
DependencyResolverclass:- [ ]
def resolve_migration_order(self, sources: list[MigrationSource]) -> list[str]— Kahn's algorithm topological sort - [ ] Raise
MigrationDependencyErroron missing dependency or circular dependency
- [ ]
- [ ] Write unit tests at
packages/maid-engine/tests/migrations/test_discovery.py: - [ ] Test
resolve_migration_orderwith linear chain (engine → stdlib → classic-rpg) - [ ] Test
resolve_migration_orderdetects circular dependency - [ ] Test
resolve_migration_orderdetects missing dependency - [ ] Test
validate_namespacerejects reserved names for untrusted packs - [ ] Test
_find_migration_directorywith mocked entry points - [ ] Test
discover_migration_sourcesdefaults to first-party packs only
5.3 Migration Planner¶
Package:
maid-engine| Priority: P0 | Dependencies: 5.2, 2.1
- [ ] Create
packages/maid-engine/src/maid_engine/migrations/planner.py - [ ]
MigrationPlannerclass:- [ ]
__init__(self, registry: PackManifestRegistry) - [ ]
async def load_migrations(self, sources: list[MigrationSource]) -> None: - [ ] For each source, scan directory for
.pyand.yamlfiles matching{sequence:04d}_{description}.{ext} - [ ] Validate trust level vs file format (
validate_migration_format) - [ ] Load Python migrations:
importlib.import_module, verifymodule.migrationinstance,isinstance(mig, Migration),validate_execution_modes - [ ] Load YAML migrations:
yaml.safe_load,_validate_yaml_schema, constructYAMLMigration - [ ]
async def create_plan(self, applied, target=None, namespace=None) -> MigrationPlan: - [ ] Filter to pending migrations (sequence > applied for each namespace)
- [ ] Apply target filtering if specified
- [ ] Order by namespace dependency then sequence
- [ ]
def get_all_migrations(self) -> dict[tuple[str, int], str]— returns(ns, seq) -> checksummap - [ ]
def get_source(self, namespace) -> MigrationSource | None
- [ ]
- [ ] YAML migration loader:
- [ ]
def load_yaml_migration(path: Path) -> YAMLMigration— usesyaml.safe_load()ONLY - [ ]
def _validate_yaml_schema(data: dict)— validate required fields, allowed operation types (add_jsonb_field,remove_jsonb_field,rename_jsonb_field,transform_jsonb_fieldwith pre-definedTransformFunctionvalues,set_schema_version) - [ ]
def _build_yaml_upgrade(operations: list[dict]) -> Callable— build async upgrade function from YAML ops - [ ]
def _build_yaml_downgrade(rollback: list[dict] | None) -> Callable | None— build async downgrade function
- [ ]
- [ ] Write unit tests at
packages/maid-engine/tests/migrations/test_planner.py: - [ ] Test loading Python migration files (with fixtures)
- [ ] Test loading YAML migration files (with fixtures)
- [ ] Test
create_planfilters already-applied migrations - [ ] Test
create_planwith target version - [ ] Test
create_planwith namespace filter - [ ] Test YAML schema validation rejects invalid operations
- [ ] Test YAML migration is restricted to TRANSACTIONAL mode for untrusted packs
- [ ] Checksum verification (merged from former Phase 5.4 — validation is part of planning):
- [ ]
async def verify_checksums(self, pool, known_migrations) -> list[ChecksumMismatch]— compare DB checksums vs file checksums - [ ]
def calculate_optimal_batch_size(document_count, avg_document_size_kb, target_batch_duration_ms=5000) -> int— empirical sizing
- [ ]
- [ ] Test checksum verification detects mismatches
- [ ] Test batch size calculation
5.4 Migration Validation — MERGED INTO 5.3¶
Checksum verification and batch size calculation have been merged into the
MigrationPlannerclass (Phase 5.3) since validation is a natural part of the planning step.
Phase 6: Migration Runner (P0)¶
6.1 Migration Runner — Execution Engine¶
Package:
maid-engine| Priority: P0 | Dependencies: 3.1, 4.3, 5.3, 2.1
- [ ] Create
packages/maid-engine/src/maid_engine/migrations/runner.py - [ ]
MigrationRunnerclass:- [ ]
__init__(self, pool, planner, history) - [ ]
async def migrate(self, target=None, namespace=None, dry_run=False, online_only=False, enabled_packs=None) -> MigrationResult: - [ ] Check for stuck
pendingstatus — refuse to proceed, log clear error, point operator tomaid db validate --cleanup-checkpoints - [ ] Call
planner.verify_checksums(pool, planner.get_all_migrations())— abort on mismatch - [ ] Call
planner.create_plan(applied, target, namespace) - [ ] If
online_only, filter toONLINE_SAFE = Truemigrations only - [ ] If
dry_run, return simulated result - [ ] Acquire global advisory lock via
MigrationAdvisoryLock - [ ] Call
_validate_requirements_under_lock(plan)— re-checkmigration_requiresafter lock - [ ] Call
_execute_migration_plan(plan) - [ ]
async def _validate_requirements_under_lock(self, plan)— re-fetch applied, validate each migration'smigration_requires - [ ]
async def _execute_migration_plan(self, plan) -> MigrationResult— iterate plan, call_execute_single_migration - [ ]
async def _execute_single_migration(self, migration): - [ ] Create
MigrationContext(all first-party packs use full context; YAML migrations use helpers only) - [ ] Apply per-migration timeouts (
asyncio.timeout) - [ ] TRANSACTIONAL mode: single transaction wrapping upgrade + history INSERT
- [ ]
SET LOCAL statement_timeoutandSET LOCAL lock_timeoutwithin transaction
- [ ]
- [ ] BATCHED/NO_TRANSACTION mode: two-phase history (pending → applied)
- [ ]
SET SESSION statement_timeoutwithRESETinfinally - [ ] Record
pendingbefore execution - [ ] Update to
appliedafter success
- [ ]
- [ ]
async def rollback(self, namespace, steps=1, force=False) -> MigrationResult - [ ]
RollbackStrategyinner class or separate: - [ ]
async def plan_rollback(self, namespace, steps, force) -> RollbackPlan - [ ]
async def _find_dependent_migrations(self, namespace, sequence) -> list[str]— usesmigration_requiressemantic checking - [ ]
async def _is_destructive_rollback(self, migration) -> bool— checksDESTRUCTIVE_ROLLBACKattribute - [ ]
async def validate_rollback_safety(self, migration) -> list[str]— returns warnings based onDESTRUCTIVE_ROLLBACKflag (migration authors set this; deferred: data-counting enhancement is P2) - [ ]
async def rollback_migration(self, namespace, sequence): - [ ] TRANSACTIONAL:
rollback_startedstatus + downgrade + rollback log + DELETE history all within same transaction (atomicity makes crash-safety marker unnecessary outside the transaction) - [ ] BATCHED: Record
rollback_startedoutside transaction (crash-safety marker), then downgrade without transaction, then rollback log + DELETE in transaction - [ ] NO_TRANSACTION: same as BATCHED
- [ ]
- [ ] Write unit tests at
packages/maid-engine/tests/migrations/test_runner.py: - [ ] Test
migratewith simple TRANSACTIONAL migration - [ ] Test
migratedry run returns plan without executing - [ ] Test
migratewithonline_only=Truefilters correctly - [ ] Test
_validate_requirements_under_lockcatches unmet requirements - [ ] Test rollback with dependent migrations fails without
--force - [ ] Test rollback records audit log entry
- [ ] Test two-phase history for BATCHED mode (pending → applied)
- [ ] Test stuck
pendingstatus blocks new migration with clear error message - [ ] Test rollback_started is inside transaction for TRANSACTIONAL mode
Phase 7: CLI Commands (P0)¶
7.1 Migration CLI — Core Commands¶
Package:
maid-engine| Priority: P0 | Dependencies: 6.1
- [ ] Create
packages/maid-engine/src/maid_engine/cli/commands/directory if needed - [ ] Create
packages/maid-engine/src/maid_engine/cli/commands/__init__.py - [ ] Create
packages/maid-engine/src/maid_engine/cli/commands/db_migrate.py: - [ ]
migratecommand:- [ ] Options:
--target,--namespace,--dry-run,--online-safe,--enabled-packs(comma-separated),--format(json|text),--backup - [ ] Single pool creation point (
asyncpg.create_pool(settings.database.dsn)) - [ ] Create
PackManifestRegistry,MigrationPlanner,MigrationHistory,MigrationRunner - [ ] Call
runner.migrate(...)with CLI arguments - [ ] Display result with
_display_migration_result(table format for text, JSON for json) - [ ] Close pool in
finally
- [ ] Options:
- [ ]
rollbackcommand:- [ ] Options:
--namespace(required),--steps(default 1),--dry-run,--force,--format - [ ] Display rollback plan with warnings before executing
- [ ] Prompt for confirmation on destructive rollbacks (unless
--force)
- [ ] Options:
- [ ]
statuscommand:- [ ] Options:
--namespace,--show-pending,--show-checkpoints,--format - [ ] Display Rich table with namespace, current version, pending count, last applied, status indicator (✓/⚠)
- [ ] Show in-progress checkpoints if
--show-checkpoints
- [ ] Options:
- [ ]
historycommand:- [ ] Options:
--namespace,--limit,--include-rollbacks,--format - [ ] Display migration history with timestamps and execution durations
- [ ] Options:
- [ ]
createcommand:- [ ] Arguments:
DESCRIPTION - [ ] Options:
--namespace(required),--template(jsonb|ddl|seed) - [ ] Generate migration file at correct path with next sequence number
- [ ] Template content based on
--templateoption
- [ ] Arguments:
- [ ]
validatecommand:- [ ] Options:
--fix-checksums,--cleanup-checkpoints,--format - [ ] Verify checksum integrity, report mismatches
- [ ] Optionally fix checksums or clean stale checkpoints
- [ ] Options:
- [ ]
baselinecommand:- [ ] Options:
--namespace(required),--at-version(required),--force - [ ] Mark existing schema as migrated to given version without running migrations
- [ ] Documented upgrade path for existing deployments:
maid db baseline --namespace=engine --at-version=2 && maid db baseline --namespace=stdlib --at-version=0
- [ ] Options:
- [ ]
cleanupcommand:- [ ] Options:
--namespace(required),--remove-tables,--remove-jsonb - [ ] Remove migration artifacts for decommissioned packs
- [ ] Options:
- [ ]
repaircommand (P1 — consolidates common recovery operations):- [ ]
--clear-pending— clear stuckpendingmigration status - [ ]
--cleanup-checkpoints— remove orphaned checkpoint rows - [ ]
--fix-checksums— re-compute checksums for applied migrations - [ ] More discoverable than spreading repair operations across
validateandcleanup
- [ ]
- [ ]
_get_enabled_packs_from_config() -> list[str]— checkMAID_ENABLED_CONTENT_PACKSenv var, fall back tosettings.migration.enabled_packs - [ ] Modify
packages/maid-engine/src/maid_engine/cli/app.py: - [ ] Replace stub
db_migratecommand with import fromcommands/db_migrate.py - [ ] Replace stub
db_initwith proper implementation that callsensure_infrastructure - [ ] Register all new db subcommands on
db_app - [ ] Write CLI integration tests at
packages/maid-engine/tests/migrations/test_cli.py: - [ ] Test
maid db migrate --dry-runoutput format - [ ] Test
maid db status --format=jsonproduces valid JSON - [ ] Test
maid db creategenerates file with correct sequence number - [ ] Test
maid db validatereports checksum mismatches
Phase 8: Initial Migration Files (P0)¶
8.1 Engine Initial Migrations¶
Package:
maid-engine| Priority: P0 | Dependencies: 6.1
- [ ] Create
packages/maid-engine/src/maid_engine/migrations/engine/directory - [ ] Create
packages/maid-engine/src/maid_engine/migrations/engine/__init__.py - [ ] Create
packages/maid-engine/src/maid_engine/migrations/engine/0001_initial_schema.py: - [ ] Migrate
SCHEMA_SQLfromDatabaseManager.initialize()into a proper migration - [ ] Create
accountstable,sessionstable, and their indexes - [ ]
ONLINE_SAFE = False,execution_mode = ExecutionMode.TRANSACTIONAL - [ ]
downgrade: DROP tables (withDESTRUCTIVE_ROLLBACK = True) - [ ] Module-level
migration = InitialSchema()instance - [ ] Create
packages/maid-engine/src/maid_engine/migrations/engine/0002_document_store.py: - [ ] Migrate
documentstable creation fromPostgresDocumentStore.initialize()into a migration - [ ] Create
documentstable withid UUID,collection VARCHAR(255),data JSONB,created_at,updated_at - [ ] Add
doc_seq BIGSERIALcolumn for keyset pagination (not present in current schema)- [ ] For new installs: column is part of initial CREATE TABLE
- [ ] For existing installs using
baseline:doc_seqis added viaALTER TABLE documents ADD COLUMN doc_seq BIGSERIAL— PostgreSQL auto-populates BIGSERIAL for existing rows; this migration does NOT use theBatchExecutor(avoids circular dependency sinceBatchExecutor.cursor_columndefaults todoc_seq)
- [ ] Create indexes:
idx_documents_collection,idx_documents_data_gin,idx_documents_collection_seq - [ ]
ONLINE_SAFE = False,execution_mode = ExecutionMode.TRANSACTIONAL - [ ]
downgrade: DROP table (DESTRUCTIVE_ROLLBACK = True) - [ ] Module-level
migration = DocumentStore()instance - [ ] Update
DatabaseManager.initialize()to detect if migrations are available and skip inlineCREATE TABLE IF NOT EXISTS - [ ] Update
PostgresDocumentStore.initialize()similarly - [ ] Write tests verifying initial migrations create correct schema
8.2 Entry Points Configuration¶
Package:
maid-engine| Priority: P0 | Dependencies: 8.1
- [ ] Modify
packages/maid-engine/pyproject.toml: - [ ] Add entry point group
[project.entry-points."maid.migrations"]withengine = "maid_engine.migrations.engine" - [ ] Add
pyyamlto dependencies if not already present (already listed) - [ ] Note:
prometheus-clientdeferred — not needed until migration system is integrated into a long-running process - [ ] Modify
packages/maid-stdlib/pyproject.toml: - [ ] Add entry point
[project.entry-points."maid.migrations"]withstdlib = "maid_stdlib.migrations"(placeholder for future) - [ ] Verify
[project.entry-points."maid.content_packs"]already exists (it does — currently empty); populate withstdlib = "maid_stdlib"for discovery - [ ] Similarly for
packages/maid-classic-rpg/pyproject.tomlandpackages/maid-tutorial-world/pyproject.toml - [ ] Run
uv syncto refresh entry points
Phase 9: Application Signaling — DEFERRED¶
Rationale: NOTIFY listener has no consumer — migrations run from CLI before/after the engine starts. NOTIFY emission adds code to every migration path for a feature with no consumer. When online/live migration support becomes a real requirement, add the listener then. Keep NOTIFY emission as a commented placeholder in the runner if desired.
9.1 PostgreSQL NOTIFY Integration — DEFERRED¶
Phase 10: Observability — DEFERRED¶
Rationale: The migration system runs as a short-lived CLI command (
maid db migrate). Addingprometheus-clientas a dependency to instrument a CLI process is unusual — there's no Prometheus scrape endpoint during CLI execution. Log structured JSON events instead for queryable observability. Defer Prometheus metrics to when the migration system is integrated into a long-running process (e.g., auto-migration on engine startup).
10.1 Prometheus Metrics — DEFERRED¶
Phase 11: End-to-End Integration Testing (P1)¶
11.1 Integration Test Fixtures¶
Package:
maid-engine| Priority: P1 | Dependencies: all above
- [ ] Create
packages/maid-engine/tests/migrations/conftest.py: - [ ]
@pytest.fixture async def migration_db()— create temporary test database, yield pool, cleanup - [ ]
@pytest.fixture def sample_migration_files(tmp_path)— create test migration .py and .yaml files - [ ]
@pytest.fixture def mock_content_packs()— createContentPackManifestinstances for engine, stdlib, classic-rpg - [ ] Create sample test migration files in
packages/maid-engine/tests/migrations/fixtures/: - [ ]
engine/0001_test_initial.py— creates a simple test table - [ ]
stdlib/0001_test_initial.py— depends on engine, creates JSONB test data - [ ]
community/0001_test_yaml.yaml— declarative YAML migration
11.2 Integration Test Scenarios¶
Package:
maid-engine| Priority: P1 | Dependencies: 11.1
- [ ] Create
packages/maid-engine/tests/migrations/test_integration.py: - [ ]
test_full_migration_lifecycle— discover, plan, execute, verify schema, rollback - [ ]
test_dependency_order_resolution— engine → stdlib → classic-rpg ordering - [ ]
test_migration_requirement_validation— classic-rpg requires stdlib:2 - [ ]
test_batched_migration_with_checkpoint_recovery— simulate interruption, verify resume - [ ]
test_concurrent_namespace_migration— two namespaces migrate serially under global lock - [ ]
test_rollback_with_dependent_packs— fails without force, succeeds with force - [ ]
test_yaml_migration_sandbox— untrusted pack restricted to YAML - [ ]
test_checksum_verification_blocks_tampered_migration— modified file detected - [ ]
test_idempotent_rename_field— rename works on mixed-schema documents - [ ]
test_baseline_then_migrate— existing DB baseline followed by new migrations - [ ]
test_stuck_pending_blocks_migration— pending status prevents migration with clear error
11.3 CLI Integration Tests¶
Package:
maid-engine| Priority: P1 | Dependencies: 7.1, 11.1
- [ ] Create
packages/maid-engine/tests/migrations/test_cli_integration.py: - [ ]
test_migrate_dry_run_output— verify text output format - [ ]
test_migrate_json_format— verify JSON output - [ ]
test_status_shows_pending— verify pending migration display - [ ]
test_rollback_prompts_for_destructive— verify confirmation prompt - [ ]
test_create_generates_file_with_next_sequence— file creation in correct directory - [ ]
test_baseline_marks_version— baseline inserts history without running
Phase 12: Documentation (P1)¶
12.1 User-Facing Documentation¶
Priority: P1 | Dependencies: 7.1
- [ ] Create
docs/guides/database-migrations.md: - [ ] Overview of the migration system
- [ ] CLI command reference with examples
- [ ] Migration authoring guide (class-based template)
- [ ] YAML migration format reference (for community packs), including
transform_jsonb_fieldfor simple pre-defined transforms (TO_INTEGER,TO_BOOLEAN, etc.) - [ ] Batching strategy guidelines (when to use TRANSACTIONAL vs BATCHED)
- [ ] ONLINE_SAFE criteria checklist
- [ ] Expand-contract pattern walkthrough
- [ ] Rollback safety and data loss warnings
- [ ] Existing database upgrade path: Document
maid db baselineworkflow for existing deployments - [ ] Update
CLAUDE.mdwith: - [ ] New
maid dbcommands in CLI reference section - [ ] Migration file paths in directory structure
- [ ]
MigrationSettingsin configuration section - [ ] Update
docs/guides/content-packs.md(or equivalent) with: - [ ] How to add migrations to a content pack
- [ ]
migration_requiresmanifest field for cross-pack dependencies - [ ] Trust level implications for community packs
12.2 Code Documentation¶
Priority: P1 | Dependencies: all code phases
- [ ] Ensure all public classes and functions have Google-style docstrings
- [ ] Add module-level docstrings to all migration modules
- [ ] Add inline comments for non-obvious algorithmic decisions (keyset pagination, checkpoint hashing, lock ID masking)
Phase 13: Production Hardening (P2)¶
13.1 Memory Management¶
Package:
maid-engine| Priority: P2 | Dependencies: 6.1
- [ ] Implement
MigrationResourceManagerclass (inrunner.pyor separate file): - [ ] Memory monitoring during migrations
- [ ] Automatic batch size reduction on
OutOfMemoryError - [ ] Memory delta logging for large migrations
- [ ] GC trigger after high-memory migrations
13.2 Adaptive Batch Sizing¶
Package:
maid-engine| Priority: P2 | Dependencies: 4.3
- [ ] Enhance
BatchExecutor.executewith optional adaptive sizing: - [ ] Monitor per-batch duration
- [ ] Increase batch size if batches complete quickly
- [ ] Decrease batch size if batches take too long
- [ ] Respect min/max bounds (100–10,000)
13.3 Rollback Safety Enhancements¶
Package:
maid-engine| Priority: P2 | Dependencies: 6.1
- [ ] Implement enhanced
validate_rollback_safety(migration) -> list[str]: - [ ] Count documents with data in fields that would be removed by rollback (upgrade from simple
DESTRUCTIVE_ROLLBACKflag to data-aware warnings) - [ ] Generate data loss warnings with document counts
- [ ] Detect type changes that may cause incompatibility on rollback
- [ ] Note: V1 uses the simpler
DESTRUCTIVE_ROLLBACK = Trueflag set by migration authors - [ ] Integrate enhanced warnings into
maid db rollbackCLI output
Dependencies Summary¶
Phase 1 (Types & Protocol):
1.1 Exceptions (standalone)
1.2 Types (standalone)
1.3 Protocol → 1.1
1.4 Security (merged into protocol.py) → 1.1
Phase 2 (Infrastructure):
2.1 History → 1.2
2.2 Checkpoint → 2.1
2.3 Settings (standalone)
Phase 3 (Locking):
3.1 Global Advisory Lock → 2.1
Phase 4 (JSONB):
4.1 JSONBUpdateSpec → 1.3
4.2 Ownership Validator — REMOVED
4.3 BatchExecutor → 2.2, 4.1
Phase 5 (Discovery):
5.1 Manifest Extension (standalone)
5.2 Discovery → 5.1, 1.4
5.3 Planner + Validation (merged) → 5.2, 2.1
Phase 6 (Runner):
6.1 Runner → 3.1, 4.3, 5.3, 2.1
Phase 7 (CLI):
7.1 CLI → 6.1
Phase 8 (Migrations):
8.1 Engine Migrations → 6.1
8.2 Entry Points → 8.1
Phase 9 (Signaling) — DEFERRED
Phase 10 (Observability) — DEFERRED
Phase 11 (Testing):
11.1-11.3 → all code phases
Phase 12 (Docs):
12.1-12.2 → 7.1 + all code
Phase 13 (Hardening):
13.1-13.3 → 6.1
New Files Summary¶
packages/maid-engine/src/maid_engine/
├── migrations/
│ ├── __init__.py # Module exports
│ ├── exceptions.py # Migration-specific exceptions
│ ├── types.py # Core data types (MigrationSource, MigrationPlan, etc.)
│ ├── protocol.py # Migration protocol, ExecutionMode, contexts, trust levels
│ ├── history.py # MigrationHistory, infrastructure tables
│ ├── checkpoint.py # CheckpointManager for batched resume
│ ├── lock.py # MigrationAdvisoryLock (global)
│ ├── jsonb_transforms.py # JSONBUpdateSpec, transform helpers
│ ├── batch_executor.py # BatchExecutor, BatchOperation protocol
│ ├── discovery.py # PackManifestRegistry, DependencyResolver
│ ├── planner.py # MigrationPlanner, YAML loader, checksum verification
│ ├── runner.py # MigrationRunner, RollbackStrategy
│ └── engine/ # Engine's own migrations
│ ├── __init__.py
│ ├── 0001_initial_schema.py
│ └── 0002_document_store.py
└── cli/
└── commands/
├── __init__.py
└── db_migrate.py # Migration CLI commands
Removed from original plan:
- security.py — merged into protocol.py
- validator.py — merged into planner.py
- listener.py — deferred (no consumer for NOTIFY in CLI context)
- metrics.py — deferred (Prometheus inappropriate for CLI tool)
packages/maid-engine/tests/migrations/ ├── init.py ├── conftest.py # Test fixtures (DB, sample files) ├── test_protocol.py # Includes trust level tests (merged from test_security.py) ├── test_history.py ├── test_checkpoint.py ├── test_lock.py ├── test_jsonb_transforms.py ├── test_batch_executor.py ├── test_discovery.py ├── test_planner.py # Includes checksum verification tests ├── test_runner.py ├── test_cli.py ├── test_integration.py ├── test_cli_integration.py └── fixtures/ ├── engine/ │ └── 0001_test_initial.py ├── stdlib/ │ └── 0001_test_initial.py └── community/ └── 0001_test_yaml.yaml ```
Modified Files Summary¶
| File | Change |
|---|---|
packages/maid-engine/src/maid_engine/config/settings.py |
Add DatabaseSettings.dsn (delegates to url), MigrationSettings, register in Settings |
packages/maid-engine/src/maid_engine/storage/database.py |
Add DatabaseManager.from_pool() classmethod with _owns_pool flag |
packages/maid-engine/src/maid_engine/storage/document_store.py |
Add PostgresDocumentStore.from_pool() classmethod with _owns_pool flag |
packages/maid-engine/src/maid_engine/plugins/manifest.py |
Add migration_requires field to ContentPackManifest |
packages/maid-engine/src/maid_engine/cli/app.py |
Replace db_migrate stub, register new db subcommands |
packages/maid-engine/pyproject.toml |
Add maid.migrations entry point; populate maid.content_packs |
packages/maid-stdlib/pyproject.toml |
Add entry points |
packages/maid-classic-rpg/pyproject.toml |
Add entry points |
packages/maid-tutorial-world/pyproject.toml |
Add entry points |
Success Criteria¶
- [ ]
maid db migrateapplies pending migrations in correct dependency order - [ ]
maid db migrate --dry-runshows plan without executing - [ ]
maid db statusshows all namespaces with current/pending versions - [ ]
maid db rollback --namespace=<ns>reverses migrations with audit trail - [ ]
maid db baselinemarks existing schema as migrated for existing deployments - [ ] Batched migrations resume correctly from checkpoint after simulated crash
- [ ] Global advisory lock serializes concurrent migration attempts
- [ ] Community packs are restricted to YAML-only declarative migrations
- [ ] Stuck
pendingstatus blocks migration with clear operator guidance - [ ] Checksum verification detects tampered migration files
- [ ]
from_pool()constructors respect_owns_poolflag on close - [ ] All new code has >80% test coverage
- [ ] All public APIs have Google-style docstrings
- [ ] MyPy strict mode passes on all new modules