"""Create spec-aligned plans and related tables. This migration creates the plans table (replacing lifecycle_plans) with a spec-aligned schema, plus the plan_projects, plan_arguments, and plan_invariants child tables. Revision ID: a5_004_spec_aligned_plans Revises: a5_003_spec_aligned_actions Create Date: 2026-02-13 00:00:01 """ from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "a5_004_spec_aligned_plans" down_revision: str | Sequence[str] | None = "a5_003_spec_aligned_actions" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: """Create spec-aligned plans + plan_projects + plan_arguments + plan_invariants.""" # --- Create plans table (ULID PK, spec-aligned columns) --- op.create_table( "v3_plans", # Identity - ULID primary key sa.Column("plan_id", sa.String(26), nullable=False), # Hierarchy - parent/root for subplan trees sa.Column( "parent_plan_id", sa.String(26), sa.ForeignKey( "v3_plans.plan_id", ondelete="SET NULL", name="fk_v3_plans_parent", ), nullable=True, ), sa.Column( "root_plan_id", sa.String(26), sa.ForeignKey( "v3_plans.plan_id", ondelete="SET NULL", name="fk_v3_plans_root", ), nullable=True, ), # Action linkage (by namespaced name, not ULID) sa.Column( "action_name", sa.String(255), sa.ForeignKey( "actions.namespaced_name", ondelete="RESTRICT", name="fk_v3_plans_action", ), nullable=False, ), # Plan naming sa.Column("namespaced_name", sa.String(255), nullable=False), sa.Column("namespace", sa.String(100), nullable=False), # Lifecycle phase and processing state (spec-aligned) sa.Column("phase", sa.String(20), nullable=False, server_default="strategize"), sa.Column( "processing_state", sa.String(20), nullable=False, server_default="queued" ), # Attempt counter sa.Column("attempt", sa.Integer(), nullable=False, server_default=sa.text("1")), # Rendered description and DoD (from action template + args) sa.Column("description", sa.Text(), nullable=False), sa.Column("definition_of_done", sa.Text(), nullable=True), # Actor references sa.Column("strategy_actor", sa.String(255), nullable=True), sa.Column("execution_actor", sa.String(255), nullable=True), sa.Column("review_actor", sa.String(255), nullable=True), sa.Column("apply_actor", sa.String(255), nullable=True), sa.Column("estimation_actor", sa.String(255), nullable=True), sa.Column("invariant_actor", sa.String(255), nullable=True), # Policy / profile sa.Column("automation_profile", sa.String(255), nullable=True), sa.Column( "automation_level", sa.String(30), nullable=False, server_default="manual" ), # Behavior sa.Column( "reusable", sa.Boolean(), nullable=False, server_default=sa.text("1") ), sa.Column( "read_only", sa.Boolean(), nullable=False, server_default=sa.text("0") ), # Inputs schema - JSON Schema dict (optional; copied from action) sa.Column("inputs_schema_json", sa.Text(), nullable=True), # Execution placeholders sa.Column("changeset_id", sa.String(26), nullable=True), sa.Column("sandbox_refs_json", sa.Text(), nullable=True), sa.Column("validation_summary_json", sa.Text(), nullable=True), sa.Column("decision_root_id", sa.String(26), nullable=True), # Error tracking sa.Column("error_message", sa.Text(), nullable=True), sa.Column("error_details_json", sa.Text(), nullable=True), # Cost/token tracking sa.Column("cost_estimate_usd", sa.Float(), nullable=True), sa.Column("cost_actual_usd", sa.Float(), nullable=True), sa.Column( "token_count_input", sa.Integer(), nullable=True, server_default=sa.text("0"), ), sa.Column( "token_count_output", sa.Integer(), nullable=True, server_default=sa.text("0"), ), # Metadata sa.Column("created_by", sa.String(255), nullable=True), sa.Column("tags_json", sa.Text(), nullable=False, server_default="[]"), # Timestamps (ISO-8601 strings) sa.Column("created_at", sa.String(30), nullable=False), sa.Column("updated_at", sa.String(30), nullable=False), sa.Column("completed_at", sa.String(30), nullable=True), sa.Column("strategize_started_at", sa.String(30), nullable=True), sa.Column("strategize_completed_at", sa.String(30), nullable=True), sa.Column("execute_started_at", sa.String(30), nullable=True), sa.Column("execute_completed_at", sa.String(30), nullable=True), sa.Column("apply_started_at", sa.String(30), nullable=True), sa.Column("applied_at", sa.String(30), nullable=True), # Constraints sa.PrimaryKeyConstraint("plan_id"), sa.CheckConstraint( "phase IN ('strategize', 'execute', 'apply', 'applied')", name="ck_v3_plans_phase", ), sa.CheckConstraint( "processing_state IN (" "'queued', 'processing', 'errored', 'complete', 'cancelled')", name="ck_v3_plans_state", ), sa.CheckConstraint( "automation_level IN ('manual', 'review_before_apply', 'full_automation')", name="ck_v3_plans_automation", ), ) # Indices for common queries op.create_index("ix_v3_plans_phase", "v3_plans", ["phase"], unique=False) op.create_index("ix_v3_plans_state", "v3_plans", ["processing_state"], unique=False) op.create_index("ix_v3_plans_parent", "v3_plans", ["parent_plan_id"], unique=False) op.create_index("ix_v3_plans_root", "v3_plans", ["root_plan_id"], unique=False) op.create_index("ix_v3_plans_created", "v3_plans", ["created_at"], unique=False) op.create_index("ix_v3_plans_action", "v3_plans", ["action_name"], unique=False) op.create_index("ix_v3_plans_namespace", "v3_plans", ["namespace"], unique=False) op.create_index( "ix_v3_plans_phase_state", "v3_plans", ["phase", "processing_state"], unique=False, ) # --- Create plan_projects table --- op.create_table( "plan_projects", sa.Column( "plan_id", sa.String(26), sa.ForeignKey( "v3_plans.plan_id", ondelete="CASCADE", name="fk_plan_projects_plan", ), nullable=False, ), sa.Column("project_name", sa.String(255), nullable=False), sa.Column("alias", sa.String(100), nullable=True), sa.Column( "read_only", sa.Boolean(), nullable=False, server_default=sa.text("0") ), sa.Column("created_at", sa.String(30), nullable=False), sa.PrimaryKeyConstraint("plan_id", "project_name"), ) op.create_index( "ix_plan_projects_project", "plan_projects", ["project_name"], unique=False, ) # --- Create plan_arguments table --- op.create_table( "plan_arguments", sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), sa.Column( "plan_id", sa.String(26), sa.ForeignKey( "v3_plans.plan_id", ondelete="CASCADE", name="fk_plan_arguments_plan", ), nullable=False, ), sa.Column("name", sa.String(100), nullable=False), sa.Column("value_json", sa.Text(), nullable=True), sa.Column("value_type", sa.String(20), nullable=False, server_default="string"), sa.Column("position", sa.Integer(), nullable=False), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("plan_id", "name", name="uq_plan_arguments_name"), ) op.create_index( "ix_plan_arguments_plan", "plan_arguments", ["plan_id"], unique=False, ) op.create_index( "ix_plan_arguments_position", "plan_arguments", ["plan_id", "position"], unique=False, ) # --- Create plan_invariants table --- op.create_table( "plan_invariants", sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), sa.Column( "plan_id", sa.String(26), sa.ForeignKey( "v3_plans.plan_id", ondelete="CASCADE", name="fk_plan_invariants_plan", ), nullable=False, ), sa.Column("invariant_text", sa.Text(), nullable=False), sa.Column( "source_scope", sa.String(20), nullable=False, server_default="plan", ), sa.Column("source_name", sa.String(255), nullable=True), sa.Column("position", sa.Integer(), nullable=False), sa.Column("created_at", sa.String(30), nullable=False), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint( "plan_id", "invariant_text", name="uq_plan_invariants_text" ), sa.CheckConstraint( "source_scope IN ('global', 'project', 'action', 'plan')", name="ck_plan_invariants_scope", ), ) op.create_index( "ix_plan_invariants_plan", "plan_invariants", ["plan_id"], unique=False, ) op.create_index( "ix_plan_invariants_position", "plan_invariants", ["plan_id", "position"], unique=False, ) def downgrade() -> None: """Drop spec-aligned plan tables.""" op.drop_index("ix_plan_invariants_position", table_name="plan_invariants") op.drop_index("ix_plan_invariants_plan", table_name="plan_invariants") op.drop_table("plan_invariants") op.drop_index("ix_plan_arguments_position", table_name="plan_arguments") op.drop_index("ix_plan_arguments_plan", table_name="plan_arguments") op.drop_table("plan_arguments") op.drop_index("ix_plan_projects_project", table_name="plan_projects") op.drop_table("plan_projects") op.drop_index("ix_v3_plans_phase_state", table_name="v3_plans") op.drop_index("ix_v3_plans_namespace", table_name="v3_plans") op.drop_index("ix_v3_plans_action", table_name="v3_plans") op.drop_index("ix_v3_plans_created", table_name="v3_plans") op.drop_index("ix_v3_plans_root", table_name="v3_plans") op.drop_index("ix_v3_plans_parent", table_name="v3_plans") op.drop_index("ix_v3_plans_state", table_name="v3_plans") op.drop_index("ix_v3_plans_phase", table_name="v3_plans") op.drop_table("v3_plans")