"""FlowPilot migration Phase 1 — schema for the unified session surface. Revision ID: f07010f17b01 Revises: 074 Create Date: 2026-04-17 Creates the backing store for the FlowPilot unified session surface: - `session_facts` — "What we know" facts, keyed to a session, with a polymorphic `source_ref` pointing at a task-lane item inside `ai_sessions.pending_task_lane` (no DB-level FK; integrity enforced at the service layer per the design doc). - `session_suggested_fixes` — AI-proposed resolution paths. Only one active (`superseded_at IS NULL`) per session at a time. - `draft_templates` — scripts pending post-resolve templatization (Option 2 in the three-option dialog). - `account_settings` — new per-account key/value settings table with a JSONB `preferences` grab-bag. Rows are created lazily on first write. - Column additions to `ai_sessions` — resolution/escalation markdown + external IDs, plus `state_version` (incremented by any write that invalidates the resolution note preview cache). - Column additions to `script_templates` — provenance fields for templates promoted from draft_templates. All four new tenant-scoped tables have RLS enabled + forced with a `tenant_isolation` policy matching the repo pattern (USING + WITH CHECK on `account_id = app.current_account_id`). Downgrade is reversible: drops in the inverse order of creation. Chained from `074` (add_network_diagrams_table) per the single-head state of production; the other local heads on feat/flowpilot-migration are branch artifacts not present in production. """ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects.postgresql import UUID, JSONB revision = "f07010f17b01" down_revision = "074" branch_labels = None depends_on = None _CURRENT_ACCOUNT = ( "COALESCE(" "NULLIF(current_setting('app.current_account_id', TRUE), ''), " "'00000000-0000-0000-0000-000000000000'" ")::uuid" ) def upgrade() -> None: # ── ai_sessions: resolution / escalation columns + state_version ─────── op.add_column( "ai_sessions", sa.Column("resolution_note_markdown", sa.Text(), nullable=True), ) op.add_column( "ai_sessions", sa.Column("resolution_note_posted_at", sa.DateTime(timezone=True), nullable=True), ) op.add_column( "ai_sessions", sa.Column("resolution_note_external_id", sa.String(128), nullable=True), ) op.add_column( "ai_sessions", sa.Column("escalation_package_markdown", sa.Text(), nullable=True), ) op.add_column( "ai_sessions", sa.Column("escalation_package_posted_at", sa.DateTime(timezone=True), nullable=True), ) op.add_column( "ai_sessions", sa.Column("escalation_package_external_id", sa.String(128), nullable=True), ) op.add_column( "ai_sessions", sa.Column( "state_version", sa.Integer(), nullable=False, server_default=sa.text("0"), ), ) # ── script_templates: provenance for post-resolve promotion ──────────── op.add_column( "script_templates", sa.Column( "source_session_id", UUID(as_uuid=True), sa.ForeignKey("ai_sessions.id"), nullable=True, ), ) op.add_column( "script_templates", sa.Column( "source_user_id", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True, ), ) op.add_column( "script_templates", sa.Column("source_ticket_ref", sa.String(64), nullable=True), ) # ── session_facts ────────────────────────────────────────────────────── op.create_table( "session_facts", sa.Column( "id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()"), ), sa.Column( "session_id", UUID(as_uuid=True), sa.ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False, ), sa.Column( "account_id", UUID(as_uuid=True), sa.ForeignKey("accounts.id"), nullable=False, ), sa.Column("text", sa.Text(), nullable=False), sa.Column("source_type", sa.String(32), nullable=False), # `source_ref` is a polymorphic pointer to a task-lane item inside # ai_sessions.pending_task_lane JSON, NOT a FK to any table. # Integrity enforced at the service layer per Section 4.2 of the # migration design doc. sa.Column("source_ref", UUID(as_uuid=True), nullable=True), sa.Column("source_summary", sa.Text(), nullable=True), sa.Column( "created_by", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False, ), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()"), ), sa.Column( "updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()"), ), sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), sa.CheckConstraint( "source_type IN ('question', 'diagnostic_check', 'user_note', 'ai_synthesis')", name="ck_session_facts_source_type", ), ) # Active-facts-per-session; partial index excludes soft-deleted rows. op.create_index( "idx_session_facts_session", "session_facts", ["session_id"], postgresql_where=sa.text("deleted_at IS NULL"), ) op.create_index( "idx_session_facts_account", "session_facts", ["account_id"], ) op.execute("ALTER TABLE session_facts ENABLE ROW LEVEL SECURITY") op.execute("ALTER TABLE session_facts FORCE ROW LEVEL SECURITY") op.execute(f""" CREATE POLICY tenant_isolation ON session_facts USING (account_id = {_CURRENT_ACCOUNT}) WITH CHECK (account_id = {_CURRENT_ACCOUNT}) """) # ── session_suggested_fixes ──────────────────────────────────────────── op.create_table( "session_suggested_fixes", sa.Column( "id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()"), ), sa.Column( "session_id", UUID(as_uuid=True), sa.ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False, ), sa.Column( "account_id", UUID(as_uuid=True), sa.ForeignKey("accounts.id"), nullable=False, ), sa.Column("title", sa.String(200), nullable=False), sa.Column("description", sa.Text(), nullable=False), sa.Column("confidence_pct", sa.Integer(), nullable=False), sa.Column( "script_template_id", UUID(as_uuid=True), sa.ForeignKey("script_templates.id"), nullable=True, ), sa.Column("ai_drafted_script", sa.Text(), nullable=True), sa.Column("ai_drafted_parameters", JSONB(), nullable=True), sa.Column("user_decision", sa.String(32), nullable=True), sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()"), ), sa.CheckConstraint( "confidence_pct BETWEEN 0 AND 100", name="ck_session_suggested_fixes_confidence_pct", ), sa.CheckConstraint( "user_decision IS NULL OR user_decision IN (" "'one_off', 'draft_template', 'build_template', 'dismissed')", name="ck_session_suggested_fixes_user_decision", ), ) # Only-one-active-per-session is enforced by service-layer supersession; # this partial index serves the "find active fix" query. op.create_index( "idx_session_suggested_fixes_session_active", "session_suggested_fixes", ["session_id"], postgresql_where=sa.text("superseded_at IS NULL"), ) op.execute("ALTER TABLE session_suggested_fixes ENABLE ROW LEVEL SECURITY") op.execute("ALTER TABLE session_suggested_fixes FORCE ROW LEVEL SECURITY") op.execute(f""" CREATE POLICY tenant_isolation ON session_suggested_fixes USING (account_id = {_CURRENT_ACCOUNT}) WITH CHECK (account_id = {_CURRENT_ACCOUNT}) """) # ── draft_templates ──────────────────────────────────────────────────── op.create_table( "draft_templates", sa.Column( "id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()"), ), sa.Column( "account_id", UUID(as_uuid=True), sa.ForeignKey("accounts.id"), nullable=False, ), sa.Column( "source_session_id", UUID(as_uuid=True), sa.ForeignKey("ai_sessions.id"), nullable=False, ), sa.Column( "source_user_id", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False, ), sa.Column("script_body", sa.Text(), nullable=False), sa.Column("proposed_parameters", JSONB(), nullable=False), sa.Column("proposed_name", sa.String(200), nullable=True), sa.Column( "proposed_category_id", UUID(as_uuid=True), sa.ForeignKey("script_categories.id"), nullable=True, ), sa.Column( "status", sa.String(32), nullable=False, server_default=sa.text("'pending'"), ), sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), sa.Column( "promoted_template_id", UUID(as_uuid=True), sa.ForeignKey("script_templates.id"), nullable=True, ), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()"), ), sa.CheckConstraint( "status IN ('pending', 'accepted', 'rejected')", name="ck_draft_templates_status", ), ) # Supports the Script Library "N scripts ready to review" badge. op.create_index( "idx_draft_templates_account_pending", "draft_templates", ["account_id"], postgresql_where=sa.text("status = 'pending'"), ) op.execute("ALTER TABLE draft_templates ENABLE ROW LEVEL SECURITY") op.execute("ALTER TABLE draft_templates FORCE ROW LEVEL SECURITY") op.execute(f""" CREATE POLICY tenant_isolation ON draft_templates USING (account_id = {_CURRENT_ACCOUNT}) WITH CHECK (account_id = {_CURRENT_ACCOUNT}) """) # ── account_settings ─────────────────────────────────────────────────── # One row per account, created lazily on first write. The `preferences` # JSONB is a grab-bag for simple settings (e.g. templatize_prompt_enabled). # Settings graduate to typed columns via future migrations when they meet # the promotion criteria in Section 4.6 of the design doc (hot path / # validation / joins). op.create_table( "account_settings", sa.Column( "account_id", UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), primary_key=True, ), sa.Column( "preferences", JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb"), ), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()"), ), sa.Column( "updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()"), ), ) op.execute("ALTER TABLE account_settings ENABLE ROW LEVEL SECURITY") op.execute("ALTER TABLE account_settings FORCE ROW LEVEL SECURITY") op.execute(f""" CREATE POLICY tenant_isolation ON account_settings USING (account_id = {_CURRENT_ACCOUNT}) WITH CHECK (account_id = {_CURRENT_ACCOUNT}) """) def downgrade() -> None: # Drop in reverse order so FK dependencies unwind cleanly. op.execute("DROP POLICY IF EXISTS tenant_isolation ON account_settings") op.execute("ALTER TABLE account_settings DISABLE ROW LEVEL SECURITY") op.drop_table("account_settings") op.execute("DROP POLICY IF EXISTS tenant_isolation ON draft_templates") op.execute("ALTER TABLE draft_templates DISABLE ROW LEVEL SECURITY") op.drop_index("idx_draft_templates_account_pending", table_name="draft_templates") op.drop_table("draft_templates") op.execute("DROP POLICY IF EXISTS tenant_isolation ON session_suggested_fixes") op.execute("ALTER TABLE session_suggested_fixes DISABLE ROW LEVEL SECURITY") op.drop_index( "idx_session_suggested_fixes_session_active", table_name="session_suggested_fixes", ) op.drop_table("session_suggested_fixes") op.execute("DROP POLICY IF EXISTS tenant_isolation ON session_facts") op.execute("ALTER TABLE session_facts DISABLE ROW LEVEL SECURITY") op.drop_index("idx_session_facts_account", table_name="session_facts") op.drop_index("idx_session_facts_session", table_name="session_facts") op.drop_table("session_facts") op.drop_column("script_templates", "source_ticket_ref") op.drop_column("script_templates", "source_user_id") op.drop_column("script_templates", "source_session_id") op.drop_column("ai_sessions", "state_version") op.drop_column("ai_sessions", "escalation_package_external_id") op.drop_column("ai_sessions", "escalation_package_posted_at") op.drop_column("ai_sessions", "escalation_package_markdown") op.drop_column("ai_sessions", "resolution_note_external_id") op.drop_column("ai_sessions", "resolution_note_posted_at") op.drop_column("ai_sessions", "resolution_note_markdown")