From 210d310fb2ce45d35f43ac40733439269eee17ed Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Fri, 17 Apr 2026 18:14:26 +0000 Subject: [PATCH] =?UTF-8?q?feat(db):=20Phase=201=20schema=20=E2=80=94=20se?= =?UTF-8?q?ssion=5Ffacts,=20suggested=5Ffixes,=20draft=5Ftemplates,=20acco?= =?UTF-8?q?unt=5Fsettings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the backing store for the FlowPilot unified session surface, per the FLOWPILOT-MIGRATION.md Phase 1 deliverable. Descends from production head 074 (add_network_diagrams_table). New tables (all tenant-scoped, all RLS-enabled + forced): - session_facts — "What we know" facts. source_ref is a polymorphic pointer to a task-lane item inside ai_sessions.pending_task_lane (no DB-level FK; integrity enforced at service layer per Section 4.2 of the design doc). Soft-delete via deleted_at; active-facts partial index excludes deleted rows. - session_suggested_fixes — AI-proposed resolutions. One active per session at a time (supersession tracked via superseded_at; partial index on (session_id) WHERE superseded_at IS NULL powers the "find active fix" query). - draft_templates — scripts pending post-resolve templatization. Partial index on (account_id) WHERE status='pending' supports the "N scripts ready to review" Script Library badge. - account_settings — new per-account table with JSONB preferences grab-bag. Rows created lazily on first write; get_setting returns default when no row exists. Column additions on ai_sessions: - resolution_note_markdown / posted_at / external_id - escalation_package_markdown / posted_at / external_id - state_version (INTEGER NOT NULL DEFAULT 0) — incremented atomically by any write that invalidates the resolution note preview cache per Section 5.5. Phase 3 consumes this. Column additions on script_templates: - source_session_id, source_user_id, source_ticket_ref — powers the "generated from CW #X · resolved by Y · used N times" provenance chip in the Script Library. RLS pattern matches the repo convention (074 / network_diagrams is the nearest template): ENABLE + FORCE, USING + WITH CHECK on `account_id = app.current_account_id`. Downgrade is reversible — drops in the inverse order of creation so FK dependencies unwind. No runtime verification from code-server; migration apply + downgrade will be verified on the new dev environment per the standing deferral. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../f07010f17b01_flowpilot_phase1_schema.py | 404 ++++++++++++++++++ 1 file changed, 404 insertions(+) create mode 100644 backend/alembic/versions/f07010f17b01_flowpilot_phase1_schema.py diff --git a/backend/alembic/versions/f07010f17b01_flowpilot_phase1_schema.py b/backend/alembic/versions/f07010f17b01_flowpilot_phase1_schema.py new file mode 100644 index 00000000..b45dec43 --- /dev/null +++ b/backend/alembic/versions/f07010f17b01_flowpilot_phase1_schema.py @@ -0,0 +1,404 @@ +"""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")