From 07968743761603f81828cfa3e28493015fbde439 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Fri, 29 May 2026 15:34:05 -0400 Subject: [PATCH] feat(l1): FlowProposal l1_session_id source linkage (nullable source_session_id + exactly-one check) Co-Authored-By: Claude Opus 4.7 --- ...a68b145_flow_proposal_l1_source_linkage.py | 61 +++++++++++++++++++ backend/app/models/flow_proposal.py | 31 ++++++++-- backend/app/models/l1_walk_session.py | 7 ++- backend/app/schemas/flow_proposal.py | 5 +- backend/tests/test_flow_proposal_l1_source.py | 16 +++++ 5 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 backend/alembic/versions/1fd88a68b145_flow_proposal_l1_source_linkage.py create mode 100644 backend/tests/test_flow_proposal_l1_source.py diff --git a/backend/alembic/versions/1fd88a68b145_flow_proposal_l1_source_linkage.py b/backend/alembic/versions/1fd88a68b145_flow_proposal_l1_source_linkage.py new file mode 100644 index 00000000..8a19abc3 --- /dev/null +++ b/backend/alembic/versions/1fd88a68b145_flow_proposal_l1_source_linkage.py @@ -0,0 +1,61 @@ +"""flow_proposal l1 source linkage + +Revision ID: 1fd88a68b145 +Revises: cb9e282267d2 +Create Date: 2026-05-29 19:33:09.188681 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = '1fd88a68b145' +down_revision: Union[str, None] = 'cb9e282267d2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "flow_proposals", + sa.Column("l1_session_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.create_index( + "ix_flow_proposals_l1_session_id", + "flow_proposals", + ["l1_session_id"], + ) + op.create_foreign_key( + "fk_flow_proposals_l1_session_id", + "flow_proposals", + "l1_walk_sessions", + ["l1_session_id"], + ["id"], + ondelete="SET NULL", + ) + op.alter_column("flow_proposals", "source_session_id", nullable=True) + op.create_check_constraint( + "ck_flow_proposals_exactly_one_source", + "flow_proposals", + "(source_session_id IS NOT NULL) <> (l1_session_id IS NOT NULL)", + ) + + +def downgrade() -> None: + op.drop_constraint( + "ck_flow_proposals_exactly_one_source", + "flow_proposals", + type_="check", + ) + op.alter_column("flow_proposals", "source_session_id", nullable=False) + op.drop_constraint( + "fk_flow_proposals_l1_session_id", + "flow_proposals", + type_="foreignkey", + ) + op.drop_index("ix_flow_proposals_l1_session_id", "flow_proposals") + op.drop_column("flow_proposals", "l1_session_id") diff --git a/backend/app/models/flow_proposal.py b/backend/app/models/flow_proposal.py index 2d95e452..52ca7bff 100644 --- a/backend/app/models/flow_proposal.py +++ b/backend/app/models/flow_proposal.py @@ -19,6 +19,7 @@ if TYPE_CHECKING: from app.models.account import Account from app.models.tree import Tree from app.models.ai_session import AISession + from app.models.l1_walk_session import L1WalkSession class FlowProposal(Base): @@ -56,6 +57,10 @@ class FlowProposal(Base): "linked_ticket_kind IS NULL OR linked_ticket_kind IN ('psa', 'internal')", name="ck_flow_proposals_linked_ticket_kind", ), + CheckConstraint( + "(source_session_id IS NOT NULL) <> (l1_session_id IS NOT NULL)", + name="ck_flow_proposals_exactly_one_source", + ), ) id: Mapped[uuid.UUID] = mapped_column( @@ -73,10 +78,16 @@ class FlowProposal(Base): nullable=True, index=True, ) - source_session_id: Mapped[uuid.UUID] = mapped_column( + source_session_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="CASCADE"), - nullable=False, + nullable=True, + index=True, + ) + l1_session_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("l1_walk_sessions.id", ondelete="SET NULL"), + nullable=True, index=True, ) @@ -164,7 +175,17 @@ class FlowProposal(Base): # ── Relationships ── account: Mapped["Account"] = relationship("Account") team: Mapped[Optional["Team"]] = relationship("Team") - source_session: Mapped["AISession"] = relationship("AISession") - target_flow: Mapped[Optional["Tree"]] = relationship("Tree", foreign_keys=[target_flow_id]) - published_flow: Mapped[Optional["Tree"]] = relationship("Tree", foreign_keys=[published_flow_id]) + source_session: Mapped[Optional["AISession"]] = relationship("AISession") + # Two FK paths exist between FlowProposal and L1WalkSession + # (FlowProposal.l1_session_id here, L1WalkSession.flow_proposal_id there), + # so each relationship must name its foreign_keys explicitly. + l1_session: Mapped[Optional["L1WalkSession"]] = relationship( + "L1WalkSession", foreign_keys="[FlowProposal.l1_session_id]" + ) + target_flow: Mapped[Optional["Tree"]] = relationship( + "Tree", foreign_keys=[target_flow_id] + ) + published_flow: Mapped[Optional["Tree"]] = relationship( + "Tree", foreign_keys=[published_flow_id] + ) reviewer: Mapped[Optional["User"]] = relationship("User") diff --git a/backend/app/models/l1_walk_session.py b/backend/app/models/l1_walk_session.py index 2595571e..7c040609 100644 --- a/backend/app/models/l1_walk_session.py +++ b/backend/app/models/l1_walk_session.py @@ -139,4 +139,9 @@ class L1WalkSession(Base): account: Mapped["Account"] = relationship("Account") created_by: Mapped["User"] = relationship("User", foreign_keys=[created_by_user_id]) flow: Mapped[Optional["Tree"]] = relationship("Tree") - flow_proposal: Mapped[Optional["FlowProposal"]] = relationship("FlowProposal") + # Two FK paths exist between L1WalkSession and FlowProposal + # (L1WalkSession.flow_proposal_id here, FlowProposal.l1_session_id there), + # so each relationship must name its foreign_keys explicitly. + flow_proposal: Mapped[Optional["FlowProposal"]] = relationship( + "FlowProposal", foreign_keys="[L1WalkSession.flow_proposal_id]" + ) diff --git a/backend/app/schemas/flow_proposal.py b/backend/app/schemas/flow_proposal.py index ca324fb5..ebb343cf 100644 --- a/backend/app/schemas/flow_proposal.py +++ b/backend/app/schemas/flow_proposal.py @@ -19,7 +19,10 @@ class FlowProposalSummary(BaseModel): supporting_session_count: int status: str target_flow_id: UUID | None = None - source_session_id: UUID + # Exactly one source is set: source_session_id (FlowPilot ai_session) XOR + # l1_session_id (L1 ai_build walk). Both are nullable on the model. + source_session_id: UUID | None = None + l1_session_id: UUID | None = None created_at: datetime model_config = {"from_attributes": True} diff --git a/backend/tests/test_flow_proposal_l1_source.py b/backend/tests/test_flow_proposal_l1_source.py new file mode 100644 index 00000000..6205a836 --- /dev/null +++ b/backend/tests/test_flow_proposal_l1_source.py @@ -0,0 +1,16 @@ +import uuid +from app.models.flow_proposal import FlowProposal + + +def test_flow_proposal_accepts_l1_session_id_without_source_session(): + p = FlowProposal( + account_id=uuid.uuid4(), + l1_session_id=uuid.uuid4(), + source_session_id=None, + proposal_type="new_flow", + title="AI L1 draft", + proposed_flow_data={"tree_structure": {"id": "root"}}, + source="ai_realtime_l1", + status="pending", + ) + assert p.l1_session_id is not None and p.source_session_id is None