# Tenant Isolation — Phase 1 Schema Migrations > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add `account_id` to every tenant table that lacks it, backfill from existing FK chains, enforce NOT NULL, and create the global content tables (`template_trees`, `platform_steps`) that replace the legacy `is_default`/`visibility='public'` patterns. **Architecture:** Each task is one Alembic migration file covering one logical domain group. Every migration follows the non-negotiable sequence: ADD nullable → backfill → verify zero NULLs → SET NOT NULL → CREATE INDEX. Any migration that cannot zero-out NULLs at step 3 must roll back in full — no partial state. RLS is NOT enabled in this phase. `get_db()` is NOT modified. Schema only. **Tech Stack:** Python 3.11 · FastAPI · SQLAlchemy 2.0 async · Alembic · PostgreSQL 16 · pytest-asyncio **Spec:** `docs/superpowers/specs/2026-04-09-tenant-data-isolation-design.md` **Prerequisite:** Phase 0 merged to `main` (PRs #131 + #132 ✓). Alembic current head: `b8d2f4a6c091`. **Task ordering note:** Task 9 (global content separation) runs before Task 10 (SET NOT NULL on trees/categories/tags/steps). This is a dependency: `is_default=TRUE` trees have `account_id=NULL` and cannot satisfy the zero-NULL check until they are moved to `template_trees`. --- ## File Map | File | Action | Group | |---|---|---| | `backend/alembic/versions/_add_account_id_core_sessions.py` | Create | 1 | | `backend/alembic/versions/_add_account_id_ai_branching.py` | Create | 2 | | `backend/alembic/versions/_add_account_id_step_ratings.py` | Create | 3 | | `backend/alembic/versions/_add_account_id_user_personalization.py` | Create | 4 | | `backend/alembic/versions/_add_account_id_psa_notifications.py` | Create | 5 | | `backend/alembic/versions/_add_account_id_maintenance.py` | Create | 6 | | `backend/alembic/versions/_add_account_id_script_tables.py` | Create | 7 | | `backend/alembic/versions/_add_account_id_target_lists.py` | Create | 8 | | `backend/alembic/versions/_create_global_content_tables.py` | Create | 9 | | `backend/alembic/versions/_set_not_null_account_id_phase1.py` | Create | 10 | | `backend/app/models/session.py` | Modify | 1 | | `backend/app/models/attachment.py` | Modify | 1 | | `backend/app/models/supporting_data.py` | Modify | 1 | | `backend/app/models/session_resolution_output.py` | Modify | 1 | | `backend/app/models/session_branch.py` | Modify | 2 | | `backend/app/models/session_handoff.py` | Modify | 2 | | `backend/app/models/fork_point.py` | Modify | 2 | | `backend/app/models/ai_session_step.py` | Modify | 2 | | `backend/app/models/ai_suggestion.py` | Modify | 2 | | `backend/app/models/step_library.py` | Modify | 3 (StepRating, StepUsageLog) | | `backend/app/models/folder.py` | Modify | 4 | | `backend/app/models/user_pinned_tree.py` | Modify | 4 | | `backend/app/models/psa_post_log.py` | Modify | 5 | | `backend/app/models/psa_member_mapping.py` | Modify | 5 | | `backend/app/models/notification_log.py` | Modify | 5 | | `backend/app/models/maintenance_schedule.py` | Modify | 6 | | `backend/app/models/script_builder_session.py` | Modify | 7 | | `backend/app/models/script_template.py` | Modify | 7 (ScriptTemplate, ScriptGeneration) | | `backend/app/models/target_list.py` | Modify | 8 | | `backend/app/models/template_tree.py` | Create | 9 | | `backend/app/models/platform_step.py` | Create | 9 | | `backend/app/models/user.py` | Modify | 10 | | `backend/app/models/tree.py` | Modify | 10 | | `backend/app/models/category.py` | Modify | 10 | | `backend/app/models/tag.py` | Modify | 10 | | `backend/app/models/step_category.py` | Modify | 10 | | `backend/app/models/step_library.py` | Modify | 10 (StepLibrary account_id NOT NULL) | | `backend/app/models/tree_embedding.py` | Modify | 10 | | `backend/app/models/feedback.py` | Modify | 10 | | `backend/tests/test_phase1_migrations.py` | Create | all tasks | --- ## Task 1: Group 1 — Core sessions **Tables:** `sessions`, `attachments`, `session_supporting_data`, `session_resolution_outputs` **Backfill paths:** - `sessions`: `sessions.user_id → users.account_id` - `attachments`: `attachments.session_id → sessions.account_id` (chain — sessions must be backfilled first in same migration) - `session_supporting_data`: same chain as attachments - `session_resolution_outputs`: `session_resolution_outputs.session_id → ai_sessions.account_id` (FK is to `ai_sessions`, not `sessions`) **Files:** - Create: `backend/alembic/versions/_add_account_id_core_sessions.py` - Modify: `backend/app/models/session.py`, `attachment.py`, `supporting_data.py`, `session_resolution_output.py` - Test: `backend/tests/test_phase1_migrations.py` --- - [ ] **Step 1.1: Create the branch** ```bash git checkout main && git pull origin main git checkout -b feat/tenant-isolation-phase-1 ``` - [ ] **Step 1.2: Write the failing test** Create `backend/tests/test_phase1_migrations.py`: ```python """Phase 1 migration tests — verify account_id backfill correctness. These tests create objects via ORM (which uses the updated models), then verify account_id is populated correctly. They run against a real PostgreSQL test DB (same as all other integration tests). """ import pytest import uuid from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, text from app.models.account import Account from app.models.user import User from app.models.tree import Tree from app.models.session import Session from app.models.attachment import Attachment from app.models.supporting_data import SessionSupportingData from app.models.session_resolution_output import SessionResolutionOutput from app.models.ai_session import AISession from app.core.security import get_password_hash # ── Helpers ────────────────────────────────────────────────────────────────── async def _make_account_and_user(db: AsyncSession, suffix: str) -> tuple[Account, User]: account = Account(name=f"Corp {suffix}", display_code=uuid.uuid4().hex[:8]) db.add(account) await db.flush() user = User( email=f"user-{suffix}-{uuid.uuid4().hex[:6]}@example.com", name=f"User {suffix}", password_hash=get_password_hash("TestPass123!"), is_active=True, account_id=account.id, account_role="engineer", ) db.add(user) await db.flush() return account, user async def _make_tree(db: AsyncSession, account: Account, user: User) -> Tree: tree = Tree( name=f"Tree {uuid.uuid4().hex[:6]}", account_id=account.id, author_id=user.id, visibility="team", tree_type="troubleshooting", tree_structure={"id": "root", "type": "start", "children": []}, is_active=True, status="published", ) db.add(tree) await db.flush() return tree async def _make_session(db: AsyncSession, account: Account, user: User, tree: Tree) -> Session: s = Session( tree_id=tree.id, user_id=user.id, account_id=account.id, tree_snapshot={}, ) db.add(s) await db.flush() return s # ── Group 1: Core sessions ──────────────────────────────────────────────────── @pytest.mark.asyncio async def test_session_account_id_matches_user(test_db: AsyncSession): """sessions.account_id must equal the user's account_id.""" account, user = await _make_account_and_user(test_db, "s1") tree = await _make_tree(test_db, account, user) session = await _make_session(test_db, account, user, tree) await test_db.commit() result = await test_db.execute(select(Session).where(Session.id == session.id)) row = result.scalar_one() assert row.account_id == account.id, f"Expected {account.id}, got {row.account_id}" @pytest.mark.asyncio async def test_attachment_account_id_matches_session(test_db: AsyncSession): """attachments.account_id must match the parent session's account_id.""" account, user = await _make_account_and_user(test_db, "att1") tree = await _make_tree(test_db, account, user) session = await _make_session(test_db, account, user, tree) attachment = Attachment( session_id=session.id, account_id=account.id, file_name="test.png", file_type="image/png", ) test_db.add(attachment) await test_db.commit() result = await test_db.execute(select(Attachment).where(Attachment.id == attachment.id)) row = result.scalar_one() assert row.account_id == account.id @pytest.mark.asyncio async def test_session_supporting_data_account_id(test_db: AsyncSession): """session_supporting_data.account_id must match parent session's account_id.""" account, user = await _make_account_and_user(test_db, "sd1") tree = await _make_tree(test_db, account, user) session = await _make_session(test_db, account, user, tree) sd = SessionSupportingData( session_id=session.id, account_id=account.id, label="Log snippet", data_type="text_snippet", content="error: connection refused", ) test_db.add(sd) await test_db.commit() result = await test_db.execute( select(SessionSupportingData).where(SessionSupportingData.id == sd.id) ) row = result.scalar_one() assert row.account_id == account.id ``` - [ ] **Step 1.3: Run test to confirm it fails (model doesn't have account_id yet)** ```bash cd backend && python -m pytest tests/test_phase1_migrations.py::test_session_account_id_matches_user -v --override-ini="addopts=" ``` Expected: FAIL — `Session` model has no `account_id` attribute. - [ ] **Step 1.4: Generate the Alembic migration file** ```bash cd backend && alembic revision -m "add_account_id_core_sessions" ``` This prints a path like `alembic/versions/xxxx_add_account_id_core_sessions.py`. Open that file and replace its contents with: ```python """add account_id to core session tables Revision ID: Revises: b8d2f4a6c091 Create Date: """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa revision: str = '' down_revision: Union[str, None] = 'b8d2f4a6c091' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # ── Step 1: ADD COLUMN (nullable) ──────────────────────────────────────── for table in ('sessions', 'attachments', 'session_supporting_data', 'session_resolution_outputs'): op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True)) op.create_foreign_key( f'fk_{table}_account_id', table, 'accounts', ['account_id'], ['id'], ondelete='CASCADE', ) # ── Step 2: BACKFILL ───────────────────────────────────────────────────── # sessions: direct join to users op.execute(""" UPDATE sessions s SET account_id = u.account_id FROM users u WHERE s.user_id = u.id AND s.account_id IS NULL """) # attachments: chain through sessions (now backfilled above) op.execute(""" UPDATE attachments a SET account_id = s.account_id FROM sessions s WHERE a.session_id = s.id AND a.account_id IS NULL """) # session_supporting_data: same chain op.execute(""" UPDATE session_supporting_data sd SET account_id = s.account_id FROM sessions s WHERE sd.session_id = s.id AND sd.account_id IS NULL """) # session_resolution_outputs: FK is to ai_sessions, not sessions op.execute(""" UPDATE session_resolution_outputs sro SET account_id = ai.account_id FROM ai_sessions ai WHERE sro.session_id = ai.id AND sro.account_id IS NULL """) # ── Step 3: VERIFY zero NULLs — raises if any remain ──────────────────── for table in ('sessions', 'attachments', 'session_supporting_data', 'session_resolution_outputs'): result = op.get_bind().execute( sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL") ) count = result.scalar() if count > 0: raise RuntimeError( f"ROLLBACK: {count} NULL account_id rows remain in {table}. " f"Fix the backfill before re-running." ) # ── Step 4: SET NOT NULL ───────────────────────────────────────────────── for table in ('sessions', 'attachments', 'session_supporting_data', 'session_resolution_outputs'): op.alter_column(table, 'account_id', nullable=False) # ── Step 5: CREATE INDEX ───────────────────────────────────────────────── for table in ('sessions', 'attachments', 'session_supporting_data', 'session_resolution_outputs'): op.create_index(f'ix_{table}_account_id', table, ['account_id']) def downgrade() -> None: for table in ('sessions', 'attachments', 'session_supporting_data', 'session_resolution_outputs'): op.drop_index(f'ix_{table}_account_id', table_name=table) op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey') op.drop_column(table, 'account_id') ``` **Important:** The `down_revision` must be `b8d2f4a6c091` (current head). Do NOT change the auto-generated `revision` value at the top. - [ ] **Step 1.5: Run the migration against the test database** ```bash cd backend && alembic upgrade head ``` Expected: `Running upgrade b8d2f4a6c091 -> , add account_id to core session tables` If it errors with "NULL rows remain", investigate the backfill SQL — there are rows whose users have NULL account_id. - [ ] **Step 1.6: Verify zero NULLs manually** ```bash cd backend && python -c " import asyncio from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy import text import os async def check(): url = os.environ.get('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@localhost:5432/resolutionflow_test') engine = create_async_engine(url) async with engine.connect() as conn: for t in ('sessions', 'attachments', 'session_supporting_data', 'session_resolution_outputs'): r = await conn.execute(text(f'SELECT COUNT(*) FROM {t} WHERE account_id IS NULL')) print(f'{t}: {r.scalar()} NULLs') await engine.dispose() asyncio.run(check()) " ``` Expected output (all zeros): ``` sessions: 0 NULLs attachments: 0 NULLs session_supporting_data: 0 NULLs session_resolution_outputs: 0 NULLs ``` - [ ] **Step 1.7: Update SQLAlchemy models** In `backend/app/models/session.py`, add after the `user_id` column (around line 33): ```python account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True, ) ``` In `backend/app/models/attachment.py`, add after the `session_id` column (around line 22): ```python account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True, ) ``` In `backend/app/models/supporting_data.py`, add after `session_id` (around line 16): ```python account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True, ) ``` In `backend/app/models/session_resolution_output.py`, add after `session_id` (around line 25): ```python account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True, ) ``` Each model also needs `from app.models.account import Account` added if missing from TYPE_CHECKING block. - [ ] **Step 1.8: Run tests** ```bash cd backend && python -m pytest tests/test_phase1_migrations.py -k "test_session or test_attachment or test_session_supporting" -v --override-ini="addopts=" ``` Expected: all 3 tests PASS. - [ ] **Step 1.9: Run full test suite** ```bash cd backend && python -m pytest --override-ini="addopts=" ``` Expected: all tests pass (no regressions from model changes). - [ ] **Step 1.10: Commit** ```bash git add backend/alembic/versions/*add_account_id_core_sessions* \ backend/app/models/session.py \ backend/app/models/attachment.py \ backend/app/models/supporting_data.py \ backend/app/models/session_resolution_output.py \ backend/tests/test_phase1_migrations.py git commit -m "feat: Phase 1 Group 1 — add account_id to core session tables Migration sequence: add nullable → backfill via user_id/ai_session chain → verify zero NULLs → SET NOT NULL → CREATE INDEX. Tables: sessions, attachments, session_supporting_data, session_resolution_outputs Co-Authored-By: Claude Sonnet 4.6 " ``` --- ## Task 2: Group 2 — AI & branching **Tables:** `session_branches`, `session_handoffs`, `fork_points`, `ai_session_steps`, `ai_suggestions` **Backfill paths:** - `session_branches`, `session_handoffs`, `fork_points`, `ai_session_steps`: all have `session_id → ai_sessions.account_id` - `ai_suggestions`: `user_id → users.account_id` **Files:** - Create: `backend/alembic/versions/_add_account_id_ai_branching.py` - Modify: `backend/app/models/session_branch.py`, `session_handoff.py`, `fork_point.py`, `ai_session_step.py`, `ai_suggestion.py` - Test: `backend/tests/test_phase1_migrations.py` --- - [ ] **Step 2.1: Write the failing tests** Append to `backend/tests/test_phase1_migrations.py`: ```python # ── Group 2: AI & branching ─────────────────────────────────────────────────── @pytest.mark.asyncio async def test_session_branch_account_id_matches_ai_session(test_db: AsyncSession): """session_branches.account_id must match parent ai_session.account_id.""" from app.models.session_branch import SessionBranch account, user = await _make_account_and_user(test_db, "sb1") ai_session = AISession( user_id=user.id, account_id=account.id, problem_summary="test", problem_domain="networking", status="active", ) test_db.add(ai_session) await test_db.flush() branch = SessionBranch( session_id=ai_session.id, account_id=account.id, label="Branch A", branch_order=1, conversation_messages=[], ) test_db.add(branch) await test_db.commit() result = await test_db.execute( select(SessionBranch).where(SessionBranch.id == branch.id) ) row = result.scalar_one() assert row.account_id == account.id @pytest.mark.asyncio async def test_ai_suggestion_account_id_matches_user(test_db: AsyncSession): """ai_suggestions.account_id must match the creating user's account_id.""" from app.models.ai_suggestion import AISuggestion account, user = await _make_account_and_user(test_db, "ais1") tree = await _make_tree(test_db, account, user) suggestion = AISuggestion( tree_id=tree.id, user_id=user.id, account_id=account.id, action_type="add_node", changes_json={}, status="pending", ) test_db.add(suggestion) await test_db.commit() result = await test_db.execute( select(AISuggestion).where(AISuggestion.id == suggestion.id) ) row = result.scalar_one() assert row.account_id == account.id ``` - [ ] **Step 2.2: Run tests to confirm they fail** ```bash cd backend && python -m pytest tests/test_phase1_migrations.py::test_session_branch_account_id_matches_ai_session -v --override-ini="addopts=" ``` Expected: FAIL — `SessionBranch` has no `account_id`. - [ ] **Step 2.3: Generate migration** ```bash cd backend && alembic revision -m "add_account_id_ai_branching" ``` Replace the generated file content with: ```python """add account_id to AI branching tables Revision ID: Revises: Create Date: """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa revision: str = '' down_revision: Union[str, None] = '' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # Step 1: ADD COLUMN (nullable) ai_tables = ('session_branches', 'session_handoffs', 'fork_points', 'ai_session_steps') for table in ai_tables: op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True)) op.create_foreign_key( f'fk_{table}_account_id', table, 'accounts', ['account_id'], ['id'], ondelete='CASCADE', ) op.add_column('ai_suggestions', sa.Column('account_id', sa.UUID(), nullable=True)) op.create_foreign_key( 'fk_ai_suggestions_account_id', 'ai_suggestions', 'accounts', ['account_id'], ['id'], ondelete='CASCADE', ) # Step 2: BACKFILL # session_branches, session_handoffs, fork_points, ai_session_steps # all FK to ai_sessions via session_id for table in ai_tables: op.execute(f""" UPDATE {table} t SET account_id = ai.account_id FROM ai_sessions ai WHERE t.session_id = ai.id AND t.account_id IS NULL """) # ai_suggestions: user_id → users.account_id op.execute(""" UPDATE ai_suggestions s SET account_id = u.account_id FROM users u WHERE s.user_id = u.id AND s.account_id IS NULL """) # Step 3: VERIFY for table in ai_tables + ('ai_suggestions',): result = op.get_bind().execute( sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL") ) count = result.scalar() if count > 0: raise RuntimeError( f"ROLLBACK: {count} NULL account_id rows in {table}." ) # Step 4: SET NOT NULL for table in ai_tables + ('ai_suggestions',): op.alter_column(table, 'account_id', nullable=False) # Step 5: CREATE INDEX for table in ai_tables + ('ai_suggestions',): op.create_index(f'ix_{table}_account_id', table, ['account_id']) def downgrade() -> None: for table in ('session_branches', 'session_handoffs', 'fork_points', 'ai_session_steps', 'ai_suggestions'): op.drop_index(f'ix_{table}_account_id', table_name=table) op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey') op.drop_column(table, 'account_id') ``` **Note:** Replace `` with the actual revision hash generated in Task 1 (check the file that was created: `revision: str = '...'`). - [ ] **Step 2.4: Run migration** ```bash cd backend && alembic upgrade head ``` - [ ] **Step 2.5: Verify zero NULLs** ```bash cd backend && python -c " import asyncio from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy import text import os async def check(): url = os.environ.get('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@localhost:5432/resolutionflow_test') engine = create_async_engine(url) async with engine.connect() as conn: for t in ('session_branches', 'session_handoffs', 'fork_points', 'ai_session_steps', 'ai_suggestions'): r = await conn.execute(text(f'SELECT COUNT(*) FROM {t} WHERE account_id IS NULL')) print(f'{t}: {r.scalar()} NULLs') await engine.dispose() asyncio.run(check()) " ``` Expected: all zeros. - [ ] **Step 2.6: Update SQLAlchemy models** In each of these files, add `account_id` as NOT NULL after the `session_id` or `user_id` column: **`backend/app/models/session_branch.py`** — add after `session_id` column (line 37): ```python account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True, ) ``` **`backend/app/models/session_handoff.py`** — add after `session_id` column (line 29): ```python account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True, ) ``` **`backend/app/models/fork_point.py`** — add after `session_id` column (line 25): ```python account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True, ) ``` **`backend/app/models/ai_session_step.py`** — add after `session_id` column (line 52): ```python account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True, comment="Denormalized from ai_sessions.account_id for direct tenant filtering.", ) ``` **`backend/app/models/ai_suggestion.py`** — add after `user_id` column (line 29): ```python account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True, ) ``` - [ ] **Step 2.7: Run tests, full suite, commit** ```bash cd backend && python -m pytest tests/test_phase1_migrations.py -k "branch or suggestion" -v --override-ini="addopts=" cd backend && python -m pytest --override-ini="addopts=" git add backend/alembic/versions/*add_account_id_ai_branching* \ backend/app/models/session_branch.py \ backend/app/models/session_handoff.py \ backend/app/models/fork_point.py \ backend/app/models/ai_session_step.py \ backend/app/models/ai_suggestion.py \ backend/tests/test_phase1_migrations.py git commit -m "feat: Phase 1 Group 2 — add account_id to AI branching tables Tables: session_branches, session_handoffs, fork_points, ai_session_steps, ai_suggestions Backfill: session_id → ai_sessions.account_id (all except ai_suggestions which uses user_id → users.account_id) Co-Authored-By: Claude Sonnet 4.6 " ``` --- ## Task 3: Group 3 — Steps & ratings **Tables:** `step_ratings`, `step_usage_log` **Note:** `session_ratings` ALREADY has `account_id NOT NULL` — do not touch it. **Backfill paths:** Both use `user_id → users.account_id` (the rating user's account, per design). **Table name:** `step_usage_log` (singular, not plural — check `StepUsageLog.__tablename__`). **Files:** - Create: `backend/alembic/versions/_add_account_id_step_ratings.py` - Modify: `backend/app/models/step_library.py` (StepRating and StepUsageLog classes) - Test: `backend/tests/test_phase1_migrations.py` --- - [ ] **Step 3.1: Write the failing tests** Append to `backend/tests/test_phase1_migrations.py`: ```python # ── Group 3: Steps & ratings ────────────────────────────────────────────────── @pytest.mark.asyncio async def test_step_rating_account_id_is_rater_account(test_db: AsyncSession): """step_ratings.account_id must be the RATER's account, not the step's account.""" from app.models.step_library import StepLibrary, StepRating account_a, user_a = await _make_account_and_user(test_db, "sr-rater") account_b, user_b = await _make_account_and_user(test_db, "sr-step-owner") # Step owned by account_b step = StepLibrary( title="A step", step_type="action", content={"text": "do something"}, created_by=user_b.id, account_id=account_b.id, visibility="public", ) test_db.add(step) await test_db.flush() # user_a (account_a) rates the step rating = StepRating( step_id=step.id, user_id=user_a.id, account_id=account_a.id, # rater's account, not step owner's was_helpful=True, is_verified_use=False, is_visible=True, ) test_db.add(rating) await test_db.commit() result = await test_db.execute(select(StepRating).where(StepRating.id == rating.id)) row = result.scalar_one() assert row.account_id == account_a.id, ( f"account_id should be rater's account ({account_a.id}), got {row.account_id}" ) ``` - [ ] **Step 3.2: Run test to confirm fail** ```bash cd backend && python -m pytest tests/test_phase1_migrations.py::test_step_rating_account_id_is_rater_account -v --override-ini="addopts=" ``` Expected: FAIL — `StepRating` has no `account_id`. - [ ] **Step 3.3: Generate migration** ```bash cd backend && alembic revision -m "add_account_id_step_ratings" ``` Replace file content: ```python """add account_id to step_ratings and step_usage_log Revision ID: Revises: Create Date: """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa revision: str = '' down_revision: Union[str, None] = '' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: for table in ('step_ratings', 'step_usage_log'): op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True)) op.create_foreign_key( f'fk_{table}_account_id', table, 'accounts', ['account_id'], ['id'], ondelete='CASCADE', ) # Backfill: from the RATER/LOGGER user's account (not the step's account) op.execute(f""" UPDATE {table} t SET account_id = u.account_id FROM users u WHERE t.user_id = u.id AND t.account_id IS NULL """) result = op.get_bind().execute( sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL") ) count = result.scalar() if count > 0: raise RuntimeError(f"ROLLBACK: {count} NULL account_id rows in {table}.") op.alter_column(table, 'account_id', nullable=False) op.create_index(f'ix_{table}_account_id', table, ['account_id']) def downgrade() -> None: for table in ('step_ratings', 'step_usage_log'): op.drop_index(f'ix_{table}_account_id', table_name=table) op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey') op.drop_column(table, 'account_id') ``` - [ ] **Step 3.4: Run migration and verify** ```bash cd backend && alembic upgrade head ``` ```bash cd backend && python -c " import asyncio from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy import text import os async def check(): url = os.environ.get('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@localhost:5432/resolutionflow_test') engine = create_async_engine(url) async with engine.connect() as conn: for t in ('step_ratings', 'step_usage_log'): r = await conn.execute(text(f'SELECT COUNT(*) FROM {t} WHERE account_id IS NULL')) print(f'{t}: {r.scalar()} NULLs') await engine.dispose() asyncio.run(check()) " ``` - [ ] **Step 3.5: Update SQLAlchemy models in `backend/app/models/step_library.py`** In the `StepRating` class (starts around line 125), add after the `user_id` column: ```python account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True, comment="Account of the RATER (not the step owner).", ) ``` In the `StepUsageLog` class (starts around line 172), add after the `user_id` column: ```python account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True, comment="Account of the user who logged this usage.", ) ``` - [ ] **Step 3.6: Run tests, full suite, commit** ```bash cd backend && python -m pytest tests/test_phase1_migrations.py::test_step_rating_account_id_is_rater_account -v --override-ini="addopts=" cd backend && python -m pytest --override-ini="addopts=" git add backend/alembic/versions/*add_account_id_step_ratings* \ backend/app/models/step_library.py \ backend/tests/test_phase1_migrations.py git commit -m "feat: Phase 1 Group 3 — add account_id to step_ratings and step_usage_log Backfill from rater/user's account_id (not the step's account_id). This is an explicit design decision — step rating data is attributed to the account that performed the rating. Co-Authored-By: Claude Sonnet 4.6 " ``` --- ## Task 4: Group 4 — User personalization **Tables:** `user_folders`, `user_pinned_trees` **Backfill:** `user_id → users.account_id` **Files:** - Create: `backend/alembic/versions/_add_account_id_user_personalization.py` - Modify: `backend/app/models/folder.py`, `backend/app/models/user_pinned_tree.py` - Test: `backend/tests/test_phase1_migrations.py` --- - [ ] **Step 4.1: Write failing tests** Append to `backend/tests/test_phase1_migrations.py`: ```python # ── Group 4: User personalization ──────────────────────────────────────────── @pytest.mark.asyncio async def test_user_folder_account_id_matches_user(test_db: AsyncSession): """user_folders.account_id must match the owning user's account_id.""" from app.models.folder import UserFolder account, user = await _make_account_and_user(test_db, "uf1") folder = UserFolder( user_id=user.id, account_id=account.id, name="My Folder", color="#6366f1", icon="folder", display_order=0, ) test_db.add(folder) await test_db.commit() result = await test_db.execute(select(UserFolder).where(UserFolder.id == folder.id)) row = result.scalar_one() assert row.account_id == account.id @pytest.mark.asyncio async def test_user_pinned_tree_account_id_matches_user(test_db: AsyncSession): """user_pinned_trees.account_id must match the pinning user's account_id.""" from app.models.user_pinned_tree import UserPinnedTree account, user = await _make_account_and_user(test_db, "pt1") tree = await _make_tree(test_db, account, user) pin = UserPinnedTree( user_id=user.id, tree_id=tree.id, account_id=account.id, display_order=0, ) test_db.add(pin) await test_db.commit() result = await test_db.execute(select(UserPinnedTree).where(UserPinnedTree.id == pin.id)) row = result.scalar_one() assert row.account_id == account.id ``` - [ ] **Step 4.2: Generate migration** ```bash cd backend && alembic revision -m "add_account_id_user_personalization" ``` ```python """add account_id to user personalization tables Revision ID: Revises: Create Date: """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa revision: str = '' down_revision: Union[str, None] = '' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: for table in ('user_folders', 'user_pinned_trees'): op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True)) op.create_foreign_key( f'fk_{table}_account_id', table, 'accounts', ['account_id'], ['id'], ondelete='CASCADE', ) op.execute(f""" UPDATE {table} t SET account_id = u.account_id FROM users u WHERE t.user_id = u.id AND t.account_id IS NULL """) result = op.get_bind().execute( sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL") ) count = result.scalar() if count > 0: raise RuntimeError(f"ROLLBACK: {count} NULL account_id rows in {table}.") op.alter_column(table, 'account_id', nullable=False) op.create_index(f'ix_{table}_account_id', table, ['account_id']) def downgrade() -> None: for table in ('user_folders', 'user_pinned_trees'): op.drop_index(f'ix_{table}_account_id', table_name=table) op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey') op.drop_column(table, 'account_id') ``` - [ ] **Step 4.3: Run migration, verify, update models, test, commit** ```bash cd backend && alembic upgrade head ``` In `backend/app/models/folder.py`, add to `UserFolder` after `user_id`: ```python account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True, ) ``` In `backend/app/models/user_pinned_tree.py`, add after `user_id`: ```python account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True, ) ``` ```bash cd backend && python -m pytest tests/test_phase1_migrations.py -k "folder or pinned" -v --override-ini="addopts=" cd backend && python -m pytest --override-ini="addopts=" git add backend/alembic/versions/*add_account_id_user_personalization* \ backend/app/models/folder.py \ backend/app/models/user_pinned_tree.py \ backend/tests/test_phase1_migrations.py git commit -m "feat: Phase 1 Group 4 — add account_id to user_folders and user_pinned_trees Co-Authored-By: Claude Sonnet 4.6 " ``` --- ## Task 5: Group 5 — PSA & notifications **Tables:** `psa_post_log`, `psa_member_mappings`, `notification_logs` **Backfill paths:** - `psa_post_log`: `psa_connection_id → psa_connections.account_id`. If `psa_connection_id` is NULL, fall back to `posted_by → users.account_id`. - `psa_member_mappings`: `psa_connection_id → psa_connections.account_id` - `notification_logs`: `notification_config_id → notification_configs.account_id` **Pre-check:** `psa_connections.account_id` is already NOT NULL ✓. `notification_configs.account_id` must also be NOT NULL — verify before running. **Files:** - Create: `backend/alembic/versions/_add_account_id_psa_notifications.py` - Modify: `backend/app/models/psa_post_log.py`, `psa_member_mapping.py`, `notification_log.py` - Test: `backend/tests/test_phase1_migrations.py` --- - [ ] **Step 5.1: Write failing tests** Append to `backend/tests/test_phase1_migrations.py`: ```python # ── Group 5: PSA & notifications ───────────────────────────────────────────── @pytest.mark.asyncio async def test_psa_member_mapping_account_id_matches_connection(test_db: AsyncSession): """psa_member_mappings.account_id must match psa_connection's account_id.""" from app.models.psa_connection import PsaConnection from app.models.psa_member_mapping import PsaMemberMapping account, user = await _make_account_and_user(test_db, "psa1") conn = PsaConnection( account_id=account.id, provider="connectwise", display_name="Test CW", site_url="https://cw.example.com", company_id="TEST", credentials_encrypted="placeholder", ) test_db.add(conn) await test_db.flush() mapping = PsaMemberMapping( psa_connection_id=conn.id, user_id=user.id, account_id=account.id, external_member_id="cw-123", external_member_name="Test User", matched_by="manual_admin", ) test_db.add(mapping) await test_db.commit() result = await test_db.execute( select(PsaMemberMapping).where(PsaMemberMapping.id == mapping.id) ) row = result.scalar_one() assert row.account_id == account.id ``` - [ ] **Step 5.2: Generate migration** ```bash cd backend && alembic revision -m "add_account_id_psa_notifications" ``` ```python """add account_id to PSA and notification tables Revision ID: Revises: Create Date: """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa revision: str = '' down_revision: Union[str, None] = '' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # Step 1: ADD COLUMN for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'): op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True)) op.create_foreign_key( f'fk_{table}_account_id', table, 'accounts', ['account_id'], ['id'], ondelete='CASCADE', ) # Step 2: BACKFILL # psa_post_log: prefer psa_connection → fallback to posted_by user op.execute(""" UPDATE psa_post_log ppl SET account_id = COALESCE(pc.account_id, u.account_id) FROM users u LEFT JOIN psa_connections pc ON pc.id = ppl.psa_connection_id WHERE ppl.posted_by = u.id AND ppl.account_id IS NULL """) # psa_member_mappings: via psa_connection op.execute(""" UPDATE psa_member_mappings pmm SET account_id = pc.account_id FROM psa_connections pc WHERE pmm.psa_connection_id = pc.id AND pmm.account_id IS NULL """) # notification_logs: via notification_config op.execute(""" UPDATE notification_logs nl SET account_id = nc.account_id FROM notification_configs nc WHERE nl.notification_config_id = nc.id AND nl.account_id IS NULL """) # Step 3: VERIFY for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'): result = op.get_bind().execute( sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL") ) count = result.scalar() if count > 0: raise RuntimeError(f"ROLLBACK: {count} NULL account_id rows in {table}.") # Step 4: SET NOT NULL for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'): op.alter_column(table, 'account_id', nullable=False) # Step 5: CREATE INDEX for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'): op.create_index(f'ix_{table}_account_id', table, ['account_id']) def downgrade() -> None: for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'): op.drop_index(f'ix_{table}_account_id', table_name=table) op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey') op.drop_column(table, 'account_id') ``` - [ ] **Step 5.3: Run migration, verify, update models, test, commit** ```bash cd backend && alembic upgrade head ``` Add `account_id` (NOT NULL, FK to accounts) to: - `backend/app/models/psa_post_log.py` — after `ai_session_id` column - `backend/app/models/psa_member_mapping.py` — after `psa_connection_id` column - `backend/app/models/notification_log.py` — after `notification_config_id` column Each follows the same pattern: ```python account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True, ) ``` ```bash cd backend && python -m pytest tests/test_phase1_migrations.py -k "psa" -v --override-ini="addopts=" cd backend && python -m pytest --override-ini="addopts=" git add backend/alembic/versions/*add_account_id_psa_notifications* \ backend/app/models/psa_post_log.py \ backend/app/models/psa_member_mapping.py \ backend/app/models/notification_log.py \ backend/tests/test_phase1_migrations.py git commit -m "feat: Phase 1 Group 5 — add account_id to PSA and notification tables psa_post_log: backfill via psa_connection, fallback to posted_by user psa_member_mappings: backfill via psa_connection notification_logs: backfill via notification_config Co-Authored-By: Claude Sonnet 4.6 " ``` --- ## Task 6: Group 6 — Maintenance **Table:** `maintenance_schedules` **Backfill path:** `tree_id → trees.account_id`. Note: `trees.account_id` is still nullable at this point. Any maintenance schedule whose tree has `account_id=NULL` (i.e., is_default=TRUE) will not backfill. Fall back to `created_by → users.account_id` for those rows. **Files:** - Create: `backend/alembic/versions/_add_account_id_maintenance.py` - Modify: `backend/app/models/maintenance_schedule.py` - Test: `backend/tests/test_phase1_migrations.py` --- - [ ] **Step 6.1: Write failing test** Append to `backend/tests/test_phase1_migrations.py`: ```python # ── Group 6: Maintenance ────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_maintenance_schedule_account_id_matches_tree(test_db: AsyncSession): """maintenance_schedules.account_id must match the tree's account_id.""" from app.models.maintenance_schedule import MaintenanceSchedule account, user = await _make_account_and_user(test_db, "ms1") tree = Tree( name="Maintenance Flow", account_id=account.id, author_id=user.id, visibility="team", tree_type="maintenance", tree_structure={"id": "root", "type": "start", "children": []}, is_active=True, status="published", ) test_db.add(tree) await test_db.flush() schedule = MaintenanceSchedule( tree_id=tree.id, account_id=account.id, created_by=user.id, cron_expression="0 9 * * 1", timezone="UTC", is_active=True, ) test_db.add(schedule) await test_db.commit() result = await test_db.execute( select(MaintenanceSchedule).where(MaintenanceSchedule.id == schedule.id) ) row = result.scalar_one() assert row.account_id == account.id ``` - [ ] **Step 6.2: Generate migration** ```bash cd backend && alembic revision -m "add_account_id_maintenance" ``` ```python """add account_id to maintenance_schedules Revision ID: Revises: Create Date: """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa revision: str = '' down_revision: Union[str, None] = '' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: op.add_column('maintenance_schedules', sa.Column('account_id', sa.UUID(), nullable=True)) op.create_foreign_key( 'fk_maintenance_schedules_account_id', 'maintenance_schedules', 'accounts', ['account_id'], ['id'], ondelete='CASCADE', ) # Primary: tree_id → trees.account_id op.execute(""" UPDATE maintenance_schedules ms SET account_id = t.account_id FROM trees t WHERE ms.tree_id = t.id AND t.account_id IS NOT NULL AND ms.account_id IS NULL """) # Fallback: created_by → users.account_id (for is_default trees with NULL account_id) op.execute(""" UPDATE maintenance_schedules ms SET account_id = u.account_id FROM users u WHERE ms.created_by = u.id AND u.account_id IS NOT NULL AND ms.account_id IS NULL """) result = op.get_bind().execute( sa.text("SELECT COUNT(*) FROM maintenance_schedules WHERE account_id IS NULL") ) count = result.scalar() if count > 0: raise RuntimeError( f"ROLLBACK: {count} maintenance_schedules rows have NULL account_id. " "Check if created_by is NULL — those rows need manual resolution." ) op.alter_column('maintenance_schedules', 'account_id', nullable=False) op.create_index('ix_maintenance_schedules_account_id', 'maintenance_schedules', ['account_id']) def downgrade() -> None: op.drop_index('ix_maintenance_schedules_account_id', table_name='maintenance_schedules') op.drop_constraint('fk_maintenance_schedules_account_id', 'maintenance_schedules', type_='foreignkey') op.drop_column('maintenance_schedules', 'account_id') ``` - [ ] **Step 6.3: Run migration, verify, update model, test, commit** ```bash cd backend && alembic upgrade head ``` In `backend/app/models/maintenance_schedule.py`, add after `created_by`: ```python account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True, ) ``` ```bash cd backend && python -m pytest tests/test_phase1_migrations.py::test_maintenance_schedule_account_id_matches_tree -v --override-ini="addopts=" cd backend && python -m pytest --override-ini="addopts=" git add backend/alembic/versions/*add_account_id_maintenance* \ backend/app/models/maintenance_schedule.py \ backend/tests/test_phase1_migrations.py git commit -m "feat: Phase 1 Group 6 — add account_id to maintenance_schedules Primary backfill: tree_id → trees.account_id Fallback: created_by → users.account_id (for is_default tree rows) Co-Authored-By: Claude Sonnet 4.6 " ``` --- ## Task 7: Group 7 — Legacy team_id tables **Tables:** `script_builder_sessions`, `script_templates`, `script_generations` **Backfill paths:** - `script_builder_sessions`: `user_id → users.account_id` - `script_templates`: `created_by → users.account_id` (`created_by` is nullable — handle with fallback) - `script_generations`: `user_id → users.account_id` **Important:** Do NOT drop `team_id` — keep it until all application code is updated. **Files:** - Create: `backend/alembic/versions/_add_account_id_script_tables.py` - Modify: `backend/app/models/script_builder_session.py`, `backend/app/models/script_template.py` (ScriptTemplate and ScriptGeneration) - Test: `backend/tests/test_phase1_migrations.py` --- - [ ] **Step 7.1: Write failing tests** Append to `backend/tests/test_phase1_migrations.py`: ```python # ── Group 7: Legacy team_id tables ─────────────────────────────────────────── @pytest.mark.asyncio async def test_script_builder_session_account_id(test_db: AsyncSession): """script_builder_sessions.account_id must match user's account_id.""" from app.models.script_builder_session import ScriptBuilderSession account, user = await _make_account_and_user(test_db, "sbs1") sbs = ScriptBuilderSession( user_id=user.id, account_id=account.id, language="powershell", ) test_db.add(sbs) await test_db.commit() result = await test_db.execute( select(ScriptBuilderSession).where(ScriptBuilderSession.id == sbs.id) ) row = result.scalar_one() assert row.account_id == account.id ``` - [ ] **Step 7.2: Generate migration** ```bash cd backend && alembic revision -m "add_account_id_script_tables" ``` ```python """add account_id to script_builder_sessions, script_templates, script_generations Revision ID: Revises: Create Date: """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa revision: str = '' down_revision: Union[str, None] = '' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: for table in ('script_builder_sessions', 'script_templates', 'script_generations'): op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True)) op.create_foreign_key( f'fk_{table}_account_id', table, 'accounts', ['account_id'], ['id'], ondelete='CASCADE', ) # script_builder_sessions: user_id → users.account_id op.execute(""" UPDATE script_builder_sessions sbs SET account_id = u.account_id FROM users u WHERE sbs.user_id = u.id AND sbs.account_id IS NULL """) # script_templates: created_by → users.account_id # created_by is nullable, so left join + only set where not null op.execute(""" UPDATE script_templates st SET account_id = u.account_id FROM users u WHERE st.created_by = u.id AND st.account_id IS NULL """) # Fallback for script_templates with NULL created_by: team_id → team admin user op.execute(""" UPDATE script_templates st SET account_id = u.account_id FROM users u WHERE u.team_id = st.team_id AND u.is_team_admin = TRUE AND st.account_id IS NULL AND EXISTS (SELECT 1 FROM users u2 WHERE u2.team_id = st.team_id) """) # script_generations: user_id → users.account_id op.execute(""" UPDATE script_generations sg SET account_id = u.account_id FROM users u WHERE sg.user_id = u.id AND sg.account_id IS NULL """) # VERIFY for table in ('script_builder_sessions', 'script_templates', 'script_generations'): result = op.get_bind().execute( sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL") ) count = result.scalar() if count > 0: raise RuntimeError(f"ROLLBACK: {count} NULL account_id rows in {table}.") for table in ('script_builder_sessions', 'script_templates', 'script_generations'): op.alter_column(table, 'account_id', nullable=False) op.create_index(f'ix_{table}_account_id', table, ['account_id']) def downgrade() -> None: for table in ('script_builder_sessions', 'script_templates', 'script_generations'): op.drop_index(f'ix_{table}_account_id', table_name=table) op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey') op.drop_column(table, 'account_id') ``` - [ ] **Step 7.3: Run migration, verify, update models, test, commit** ```bash cd backend && alembic upgrade head ``` In `backend/app/models/script_builder_session.py`, add after `user_id`: ```python account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True, ) ``` In `backend/app/models/script_template.py`: - In `ScriptTemplate` class, add after `team_id`: ```python account_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True, ) ``` - In `ScriptGeneration` class, add after `user_id`: ```python account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True, ) ``` ```bash cd backend && python -m pytest tests/test_phase1_migrations.py::test_script_builder_session_account_id -v --override-ini="addopts=" cd backend && python -m pytest --override-ini="addopts=" git add backend/alembic/versions/*add_account_id_script_tables* \ backend/app/models/script_builder_session.py \ backend/app/models/script_template.py \ backend/tests/test_phase1_migrations.py git commit -m "feat: Phase 1 Group 7 — add account_id to script tables (keep team_id) team_id is kept in all three tables — drop deferred until app code is fully migrated off team_id references. Tables: script_builder_sessions, script_templates, script_generations Backfill: user_id/created_by → users.account_id Co-Authored-By: Claude Sonnet 4.6 " ``` --- ## Task 8: Group 8 — TargetList **Table:** `target_lists` **Backfill path:** `team_id → users WHERE is_team_admin=TRUE → account_id` **Context:** Zero rows in production (confirmed 2026-04-09). The migration is schema-only in practice but must be correct for any future rows. The `team_id` FK to `teams` is NOT NULL — keep it. Do NOT drop it. **Files:** - Create: `backend/alembic/versions/_add_account_id_target_lists.py` - Modify: `backend/app/models/target_list.py` - Test: `backend/tests/test_phase1_migrations.py` --- - [ ] **Step 8.1: Write failing test** Append to `backend/tests/test_phase1_migrations.py`: ```python # ── Group 8: TargetList ──────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_target_list_account_id_from_team_admin(test_db: AsyncSession): """target_lists.account_id must be set to the team admin's account_id.""" from app.models.target_list import TargetList from app.models.team import Team account, user = await _make_account_and_user(test_db, "tl1") # Make user a team admin team = Team(name=f"Team {uuid.uuid4().hex[:6]}") test_db.add(team) await test_db.flush() user.team_id = team.id user.is_team_admin = True await test_db.flush() target_list = TargetList( team_id=team.id, account_id=account.id, created_by=user.id, name="Server Targets", targets=[{"label": "SRV-01"}], ) test_db.add(target_list) await test_db.commit() result = await test_db.execute( select(TargetList).where(TargetList.id == target_list.id) ) row = result.scalar_one() assert row.account_id == account.id ``` - [ ] **Step 8.2: Generate migration** ```bash cd backend && alembic revision -m "add_account_id_target_lists" ``` ```python """add account_id to target_lists (keep team_id) Revision ID: Revises: Create Date: """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa revision: str = '' down_revision: Union[str, None] = '' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: op.add_column('target_lists', sa.Column('account_id', sa.UUID(), nullable=True)) op.create_foreign_key( 'fk_target_lists_account_id', 'target_lists', 'accounts', ['account_id'], ['id'], ondelete='CASCADE', ) # Backfill: team_id → team admin user → account_id # If any row cannot be backfilled (no team admin found) → ROLLBACK op.execute(""" UPDATE target_lists tl SET account_id = u.account_id FROM users u WHERE u.team_id = tl.team_id AND u.is_team_admin = TRUE AND u.account_id IS NOT NULL AND tl.account_id IS NULL """) # Secondary fallback: created_by user op.execute(""" UPDATE target_lists tl SET account_id = u.account_id FROM users u WHERE tl.created_by = u.id AND u.account_id IS NOT NULL AND tl.account_id IS NULL """) result = op.get_bind().execute( sa.text("SELECT COUNT(*) FROM target_lists WHERE account_id IS NULL") ) count = result.scalar() if count > 0: raise RuntimeError( f"ROLLBACK: {count} target_lists rows have NULL account_id. " "No team admin found for these teams. Resolve before re-running." ) op.alter_column('target_lists', 'account_id', nullable=False) op.create_index('ix_target_lists_account_id', 'target_lists', ['account_id']) def downgrade() -> None: op.drop_index('ix_target_lists_account_id', table_name='target_lists') op.drop_constraint('fk_target_lists_account_id', 'target_lists', type_='foreignkey') op.drop_column('target_lists', 'account_id') ``` - [ ] **Step 8.3: Run migration, verify, update model, test, commit** ```bash cd backend && alembic upgrade head ``` In `backend/app/models/target_list.py`, add after `team_id`: ```python account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True, ) ``` Also add the Account import to TYPE_CHECKING: ```python if TYPE_CHECKING: from app.models.user import User from app.models.team import Team from app.models.account import Account ``` ```bash cd backend && python -m pytest tests/test_phase1_migrations.py::test_target_list_account_id_from_team_admin -v --override-ini="addopts=" cd backend && python -m pytest --override-ini="addopts=" git add backend/alembic/versions/*add_account_id_target_lists* \ backend/app/models/target_list.py \ backend/tests/test_phase1_migrations.py git commit -m "feat: Phase 1 Group 8 — add account_id to target_lists (keep team_id) Zero rows in production — this is a schema-only migration in practice. team_id kept for app code compatibility. Drop deferred to later cleanup. Backfill: team_id → team admin user → account_id; fallback: created_by. Co-Authored-By: Claude Sonnet 4.6 " ``` --- ## Task 9: Group 10 — Global content separation (runs before Group 9) **Why this runs before Task 10:** `trees` has `account_id=NULL` for `is_default=TRUE` rows (platform trees). Task 10 sets trees.account_id NOT NULL, which would fail without first handling these rows. This task moves them to `template_trees` (no account_id column), then Task 10 can safely SET NOT NULL on trees. **Action:** 1. Create `template_trees` table — stores platform-owned troubleshooting trees (no account_id, no RLS) 2. Create `platform_steps` table — stores platform-owned steps (no account_id, no RLS) 3. Copy `is_default=TRUE` trees to `template_trees` 4. Copy `visibility='public'` steps from `step_library` to `platform_steps` 5. Remove the copied rows from `trees` (set `is_default=FALSE` and assign a NULL-safe account) — or delete if no sessions reference them 6. Handle global `tree_categories`, `tree_tags`, `step_categories` (NULL `account_id` rows = global platform items) — assign to a "ResolutionFlow Platform" internal account created in this migration **Files:** - Create: `backend/alembic/versions/_create_global_content_tables.py` - Create: `backend/app/models/template_tree.py` - Create: `backend/app/models/platform_step.py` - Modify: `backend/app/models/__init__.py` (register new models) - Test: `backend/tests/test_phase1_migrations.py` --- - [ ] **Step 9.1: Write failing tests** Append to `backend/tests/test_phase1_migrations.py`: ```python # ── Group 10 (runs first): Global content tables ────────────────────────────── @pytest.mark.asyncio async def test_template_trees_table_exists_and_has_no_account_id(test_db: AsyncSession): """template_trees must exist and must NOT have an account_id column.""" result = await test_db.execute(text(""" SELECT column_name FROM information_schema.columns WHERE table_name = 'template_trees' """)) columns = {row[0] for row in result.fetchall()} assert 'id' in columns, "template_trees.id must exist" assert 'account_id' not in columns, "template_trees must not have account_id (global content)" @pytest.mark.asyncio async def test_platform_steps_table_exists_and_has_no_account_id(test_db: AsyncSession): """platform_steps must exist and must NOT have an account_id column.""" result = await test_db.execute(text(""" SELECT column_name FROM information_schema.columns WHERE table_name = 'platform_steps' """)) columns = {row[0] for row in result.fetchall()} assert 'id' in columns, "platform_steps.id must exist" assert 'account_id' not in columns, "platform_steps must not have account_id (global content)" ``` - [ ] **Step 9.2: Generate migration** ```bash cd backend && alembic revision -m "create_global_content_tables" ``` ```python """create template_trees and platform_steps global content tables Revision ID: Revises: Create Date: These tables hold platform-owned content that is readable by all authenticated users. No account_id. No RLS. Ever. """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa from sqlalchemy.dialects.postgresql import UUID, JSONB revision: str = '' down_revision: Union[str, None] = '' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # ── Create template_trees ───────────────────────────────────────────────── op.create_table( 'template_trees', sa.Column('id', UUID(), primary_key=True), sa.Column('name', sa.String(255), nullable=False), sa.Column('description', sa.Text(), nullable=True), sa.Column('category', sa.String(100), nullable=True), sa.Column('tree_type', sa.String(20), nullable=False), sa.Column('tree_structure', JSONB(), nullable=False), sa.Column('tags', JSONB(), nullable=False, server_default='[]'), sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), # source_tree_id: original tree this was promoted from (nullable) sa.Column('source_tree_id', UUID(), sa.ForeignKey('trees.id', ondelete='SET NULL'), nullable=True), ) op.create_index('ix_template_trees_tree_type', 'template_trees', ['tree_type']) # ── Create platform_steps ──────────────────────────────────────────────── op.create_table( 'platform_steps', sa.Column('id', UUID(), primary_key=True), sa.Column('title', sa.String(255), nullable=False), sa.Column('step_type', sa.String(50), nullable=False), sa.Column('content', JSONB(), nullable=False), sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), # source_step_id: original step this was promoted from (nullable) sa.Column('source_step_id', UUID(), sa.ForeignKey('step_library.id', ondelete='SET NULL'), nullable=True), ) op.create_index('ix_platform_steps_step_type', 'platform_steps', ['step_type']) # ── Migrate is_default=TRUE trees → template_trees ───────────────────── op.execute(""" INSERT INTO template_trees (id, name, description, category, tree_type, tree_structure, is_active, created_at, updated_at, source_tree_id) SELECT gen_random_uuid(), name, description, category, tree_type, tree_structure, is_active, COALESCE(created_at, NOW()), COALESCE(updated_at, NOW()), id FROM trees WHERE is_default = TRUE """) # ── Migrate visibility='public' steps → platform_steps ───────────────── op.execute(""" INSERT INTO platform_steps (id, title, step_type, content, is_active, created_at, updated_at, source_step_id) SELECT gen_random_uuid(), title, step_type, content, is_active, COALESCE(created_at, NOW()), COALESCE(updated_at, NOW()), id FROM step_library WHERE visibility = 'public' """) # ── Create a ResolutionFlow platform account for global content ────────── # Used to satisfy NOT NULL on trees, tree_categories, tree_tags, etc. # This is a sentinel account — it is NOT a real customer account. op.execute(""" INSERT INTO accounts (id, name, display_code, created_at, updated_at) VALUES ( '00000000-0000-0000-0000-000000000001', 'ResolutionFlow Platform', 'PLATFORM', NOW(), NOW() ) ON CONFLICT (id) DO NOTHING """) # ── Assign is_default trees to platform account ────────────────────────── op.execute(""" UPDATE trees SET account_id = '00000000-0000-0000-0000-000000000001' WHERE is_default = TRUE AND account_id IS NULL """) # ── Assign global tree_categories (team_id=NULL, account_id=NULL) ──────── op.execute(""" UPDATE tree_categories SET account_id = '00000000-0000-0000-0000-000000000001' WHERE account_id IS NULL """) # ── Assign global tree_tags (team_id=NULL, account_id=NULL) ───────────── op.execute(""" UPDATE tree_tags SET account_id = '00000000-0000-0000-0000-000000000001' WHERE account_id IS NULL """) # ── Assign global step_categories (account_id=NULL) ────────────────────── op.execute(""" UPDATE step_categories SET account_id = '00000000-0000-0000-0000-000000000001' WHERE account_id IS NULL """) # ── Assign global step_library entries (visibility='public', account_id=NULL) ─ op.execute(""" UPDATE step_library SET account_id = '00000000-0000-0000-0000-000000000001' WHERE account_id IS NULL """) # ── Verify all target tables now have zero NULLs ───────────────────────── for table in ('trees', 'tree_categories', 'tree_tags', 'step_categories', 'step_library'): result = op.get_bind().execute( sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL") ) count = result.scalar() if count > 0: raise RuntimeError( f"ROLLBACK: {count} NULL account_id rows remain in {table} " "after platform account assignment. Investigate before re-running." ) def downgrade() -> None: # Reverse platform account assignments (set back to NULL where platform account) platform_id = '00000000-0000-0000-0000-000000000001' for table in ('trees', 'tree_categories', 'tree_tags', 'step_categories', 'step_library'): op.execute(f"UPDATE {table} SET account_id = NULL WHERE account_id = '{platform_id}'") op.execute(f"DELETE FROM accounts WHERE id = '{platform_id}'") op.drop_index('ix_platform_steps_step_type', table_name='platform_steps') op.drop_index('ix_template_trees_tree_type', table_name='template_trees') op.drop_table('platform_steps') op.drop_table('template_trees') ``` - [ ] **Step 9.3: Create the SQLAlchemy model files** Create `backend/app/models/template_tree.py`: ```python """Template tree model — platform-owned troubleshooting trees, readable by all users. No account_id. No RLS. Readable by any authenticated user. Populated by promoting is_default=TRUE trees from the trees table. """ import uuid from datetime import datetime, timezone from typing import Optional, Any from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.dialects.postgresql import UUID, JSONB from app.core.database import Base class TemplateTree(Base): __tablename__ = "template_trees" id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) tree_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) tree_structure: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) tags: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) source_tree_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("trees.id", ondelete="SET NULL"), nullable=True, ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), ) ``` Create `backend/app/models/platform_step.py`: ```python """Platform step model — platform-owned steps, readable by all users. No account_id. No RLS. Readable by any authenticated user. Populated by promoting visibility='public' steps from step_library. """ import uuid from datetime import datetime, timezone from typing import Optional, Any from sqlalchemy import String, Boolean, DateTime, ForeignKey from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.dialects.postgresql import UUID, JSONB from app.core.database import Base class PlatformStep(Base): __tablename__ = "platform_steps" id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) title: Mapped[str] = mapped_column(String(255), nullable=False) step_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True) content: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) source_step_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("step_library.id", ondelete="SET NULL"), nullable=True, ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), ) ``` In `backend/app/models/__init__.py`, add: ```python from .template_tree import TemplateTree from .platform_step import PlatformStep ``` And add `"TemplateTree"` and `"PlatformStep"` to the `__all__` list. - [ ] **Step 9.4: Run migration, run tests, commit** ```bash cd backend && alembic upgrade head cd backend && python -m pytest tests/test_phase1_migrations.py -k "template_trees or platform_steps" -v --override-ini="addopts=" cd backend && python -m pytest --override-ini="addopts=" git add backend/alembic/versions/*create_global_content_tables* \ backend/app/models/template_tree.py \ backend/app/models/platform_step.py \ backend/app/models/__init__.py \ backend/tests/test_phase1_migrations.py git commit -m "feat: Phase 1 Group 10 — create global content tables and platform account Creates template_trees and platform_steps (no account_id, no RLS). Migrates is_default=TRUE trees and public steps into them. Creates sentinel platform account (00000000-...-0001) for global tree_categories, tree_tags, step_categories, step_library, and is_default trees — clearing all NULL account_id rows in those tables as prerequisite for Group 9 SET NOT NULL. Co-Authored-By: Claude Sonnet 4.6 " ``` --- ## Task 10: Group 9 — SET NOT NULL on existing nullable account_id columns **Why this runs last:** Depends on Task 9 having cleared all NULL account_id rows via platform account assignment. **Tables:** `users`, `trees`, `tree_categories`, `tree_tags`, `step_categories`, `step_library`, `tree_embeddings`, `feedback` **Action:** For each table: 1. Verify zero NULLs (if any remain, backfill or delete) 2. SET NOT NULL 3. If index doesn't already exist, CREATE INDEX **Special cases:** - `users.account_id`: Any user with NULL account_id must be investigated — they are orphaned. If none, proceed. - `tree_embeddings.account_id`: Backfill from `tree_id → trees.account_id` (trees now all have account_id after Task 9). - `feedback.account_id`: Backfill from `user_id → users.account_id`. **Files:** - Create: `backend/alembic/versions/_set_not_null_account_id_phase1.py` - Modify: `backend/app/models/user.py`, `tree.py`, `category.py`, `tag.py`, `step_category.py`, `step_library.py` (StepLibrary), `tree_embedding.py`, `feedback.py` - Test: `backend/tests/test_phase1_migrations.py` --- - [ ] **Step 10.1: Write failing tests** Append to `backend/tests/test_phase1_migrations.py`: ```python # ── Group 9: SET NOT NULL on existing nullable columns ──────────────────────── @pytest.mark.asyncio async def test_tree_account_id_is_not_null(test_db: AsyncSession): """trees.account_id must be NOT NULL after Phase 1 — enforced at DB level.""" # Try to insert a tree with no account_id — must fail from sqlalchemy.exc import IntegrityError with pytest.raises(IntegrityError): test_db.add(Tree( name="Bad tree", # account_id intentionally omitted author_id=None, visibility="private", tree_type="troubleshooting", tree_structure={}, is_active=True, status="draft", )) await test_db.flush() @pytest.mark.asyncio async def test_user_account_id_is_not_null(test_db: AsyncSession): """users.account_id must be NOT NULL after Phase 1.""" from sqlalchemy.exc import IntegrityError with pytest.raises(IntegrityError): test_db.add(User( email=f"orphan-{uuid.uuid4().hex[:6]}@example.com", name="Orphan", password_hash=get_password_hash("x"), is_active=True, # account_id intentionally omitted )) await test_db.flush() ``` - [ ] **Step 10.2: Generate migration** ```bash cd backend && alembic revision -m "set_not_null_account_id_phase1" ``` ```python """set NOT NULL on all previously-nullable account_id columns Revision ID: Revises: Create Date: All tables in this migration had account_id set to nullable previously. Task 9 (create_global_content_tables) cleared all NULL rows. This migration enforces the NOT NULL constraint. """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa revision: str = '' down_revision: Union[str, None] = '' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # tree_embeddings: backfill from trees (must happen before SET NOT NULL) op.execute(""" UPDATE tree_embeddings te SET account_id = t.account_id FROM trees t WHERE te.tree_id = t.id AND te.account_id IS NULL """) # feedback: backfill from users op.execute(""" UPDATE feedback f SET account_id = u.account_id FROM users u WHERE f.user_id = u.id AND f.account_id IS NULL """) # Verify ALL tables before touching any SET NOT NULL tables_with_account_id = [ 'users', 'trees', 'tree_categories', 'tree_tags', 'step_categories', 'step_library', 'tree_embeddings', 'feedback', ] for table in tables_with_account_id: result = op.get_bind().execute( sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL") ) count = result.scalar() if count > 0: raise RuntimeError( f"ROLLBACK: {count} NULL account_id rows in {table}. " "Run Task 9 (create_global_content_tables) first, or " "manually backfill/delete orphaned rows." ) # SET NOT NULL on all for table in tables_with_account_id: op.alter_column(table, 'account_id', nullable=False) # Create indexes where they don't already exist # (some tables like trees already have ix_trees_account_id from prior work) new_indexes = [ ('tree_embeddings', 'ix_tree_embeddings_account_id'), ('feedback', 'ix_feedback_account_id'), ] for table, index_name in new_indexes: # Check if index exists to avoid duplicate error result = op.get_bind().execute(sa.text( f"SELECT 1 FROM pg_indexes WHERE tablename='{table}' AND indexname='{index_name}'" )) if not result.fetchone(): op.create_index(index_name, table, ['account_id']) def downgrade() -> None: # Revert to nullable for table in ('users', 'trees', 'tree_categories', 'tree_tags', 'step_categories', 'step_library', 'tree_embeddings', 'feedback'): op.alter_column(table, 'account_id', nullable=True) for table, index_name in ( ('tree_embeddings', 'ix_tree_embeddings_account_id'), ('feedback', 'ix_feedback_account_id'), ): try: op.drop_index(index_name, table_name=table) except Exception: pass ``` - [ ] **Step 10.3: Run migration** ```bash cd backend && alembic upgrade head ``` If this errors with "NULL account_id rows remain in users", investigate: ```sql -- Run from VPS SSH via docker exec SELECT id, email, account_id FROM users WHERE account_id IS NULL; ``` These are orphaned users. Either assign them to an account or delete them if they are test/seed data. - [ ] **Step 10.4: Update SQLAlchemy models — change `Mapped[Optional[uuid.UUID]]` to `Mapped[uuid.UUID]` and `nullable=True` to `nullable=False`** **`backend/app/models/user.py`** — find `account_id` (around line 46) and change: ```python # BEFORE: account_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="RESTRICT"), nullable=True, ... ) # AFTER: account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="RESTRICT"), nullable=False, index=True, ) ``` **`backend/app/models/tree.py`** — find `account_id` (around line 79) and change: ```python # BEFORE: account_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ..., nullable=True, ... ) # AFTER: account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True, ) ``` Apply the same pattern (`Optional` → required, `nullable=True` → `nullable=False`) to: - `backend/app/models/category.py` — `TreeCategory.account_id` - `backend/app/models/tag.py` — `TreeTag.account_id` - `backend/app/models/step_category.py` — `StepCategory.account_id` - `backend/app/models/step_library.py` — `StepLibrary.account_id` - `backend/app/models/tree_embedding.py` — `TreeEmbedding.account_id` - `backend/app/models/feedback.py` — `Feedback.account_id` - [ ] **Step 10.5: Run tests, full suite, commit** ```bash cd backend && python -m pytest tests/test_phase1_migrations.py -v --override-ini="addopts=" cd backend && python -m pytest --override-ini="addopts=" ``` If any existing tests fail because they create objects without `account_id`, update those test fixtures to provide the required field. ```bash git add backend/alembic/versions/*set_not_null_account_id* \ backend/app/models/user.py \ backend/app/models/tree.py \ backend/app/models/category.py \ backend/app/models/tag.py \ backend/app/models/step_category.py \ backend/app/models/step_library.py \ backend/app/models/tree_embedding.py \ backend/app/models/feedback.py \ backend/tests/test_phase1_migrations.py git commit -m "feat: Phase 1 Group 9 — enforce NOT NULL on all account_id columns All previously-nullable account_id columns are now NOT NULL. tree_embeddings and feedback backfilled before constraint applied. Global content assigned to platform sentinel account (00000000-...-0001) in preceding migration. Tables updated: users, trees, tree_categories, tree_tags, step_categories, step_library, tree_embeddings, feedback Co-Authored-By: Claude Sonnet 4.6 " ``` --- ## Task 11: Phase 1 gate verification **Run the gate verification query across all tenant tables. All must return zero NULLs.** **Files:** No code changes — verification only. --- - [ ] **Step 11.1: Run the gate verification query** From VPS SSH: ```bash docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow -c " SELECT tablename, null_count FROM ( SELECT 'sessions' AS tablename, COUNT(*) FILTER (WHERE account_id IS NULL) AS null_count FROM sessions UNION ALL SELECT 'attachments', COUNT(*) FILTER (WHERE account_id IS NULL) FROM attachments UNION ALL SELECT 'session_supporting_data', COUNT(*) FILTER (WHERE account_id IS NULL) FROM session_supporting_data UNION ALL SELECT 'session_resolution_outputs',COUNT(*) FILTER (WHERE account_id IS NULL) FROM session_resolution_outputs UNION ALL SELECT 'session_branches', COUNT(*) FILTER (WHERE account_id IS NULL) FROM session_branches UNION ALL SELECT 'session_handoffs', COUNT(*) FILTER (WHERE account_id IS NULL) FROM session_handoffs UNION ALL SELECT 'fork_points', COUNT(*) FILTER (WHERE account_id IS NULL) FROM fork_points UNION ALL SELECT 'ai_session_steps', COUNT(*) FILTER (WHERE account_id IS NULL) FROM ai_session_steps UNION ALL SELECT 'ai_suggestions', COUNT(*) FILTER (WHERE account_id IS NULL) FROM ai_suggestions UNION ALL SELECT 'step_ratings', COUNT(*) FILTER (WHERE account_id IS NULL) FROM step_ratings UNION ALL SELECT 'step_usage_log', COUNT(*) FILTER (WHERE account_id IS NULL) FROM step_usage_log UNION ALL SELECT 'user_folders', COUNT(*) FILTER (WHERE account_id IS NULL) FROM user_folders UNION ALL SELECT 'user_pinned_trees', COUNT(*) FILTER (WHERE account_id IS NULL) FROM user_pinned_trees UNION ALL SELECT 'psa_post_log', COUNT(*) FILTER (WHERE account_id IS NULL) FROM psa_post_log UNION ALL SELECT 'psa_member_mappings', COUNT(*) FILTER (WHERE account_id IS NULL) FROM psa_member_mappings UNION ALL SELECT 'notification_logs', COUNT(*) FILTER (WHERE account_id IS NULL) FROM notification_logs UNION ALL SELECT 'maintenance_schedules', COUNT(*) FILTER (WHERE account_id IS NULL) FROM maintenance_schedules UNION ALL SELECT 'script_builder_sessions', COUNT(*) FILTER (WHERE account_id IS NULL) FROM script_builder_sessions UNION ALL SELECT 'script_templates', COUNT(*) FILTER (WHERE account_id IS NULL) FROM script_templates UNION ALL SELECT 'script_generations', COUNT(*) FILTER (WHERE account_id IS NULL) FROM script_generations UNION ALL SELECT 'target_lists', COUNT(*) FILTER (WHERE account_id IS NULL) FROM target_lists UNION ALL SELECT 'users', COUNT(*) FILTER (WHERE account_id IS NULL) FROM users UNION ALL SELECT 'trees', COUNT(*) FILTER (WHERE account_id IS NULL) FROM trees UNION ALL SELECT 'tree_categories', COUNT(*) FILTER (WHERE account_id IS NULL) FROM tree_categories UNION ALL SELECT 'tree_tags', COUNT(*) FILTER (WHERE account_id IS NULL) FROM tree_tags UNION ALL SELECT 'step_categories', COUNT(*) FILTER (WHERE account_id IS NULL) FROM step_categories UNION ALL SELECT 'step_library', COUNT(*) FILTER (WHERE account_id IS NULL) FROM step_library UNION ALL SELECT 'tree_embeddings', COUNT(*) FILTER (WHERE account_id IS NULL) FROM tree_embeddings UNION ALL SELECT 'feedback', COUNT(*) FILTER (WHERE account_id IS NULL) FROM feedback ) t ORDER BY null_count DESC, tablename; " ``` Expected: all rows show `null_count = 0`. Any non-zero row is a blocker — do not proceed to Phase 2 until resolved. - [ ] **Step 11.2: Verify CI is still green** ```bash gh run list --limit 3 ``` Check that the latest CI run on `feat/tenant-isolation-phase-1` is green. The tenant filter check will now report fewer warnings (tables that gained account_id no longer trigger false positives). - [ ] **Step 11.3: Create PR** ```bash git push -u origin feat/tenant-isolation-phase-1 gh pr create \ --base main \ --title "feat: tenant isolation Phase 1 — add account_id to all tenant tables" \ --body "Adds account_id NOT NULL to all tenant tables, creates global content tables, and enforces the platform account sentinel for legacy global content. Phase 2 (RLS + SET LOCAL in get_db) is unblocked once this merges and gate query returns all zeros." ``` --- ## Phase 1 Gate Checklist Before merging and declaring Phase 1 complete: - [ ] All 10 migrations in `alembic/versions/` chained correctly (`down_revision` points to previous) - [ ] All migrations run cleanly: `alembic upgrade head` exits 0 - [ ] All 28 tenant tables show `null_count = 0` in gate verification query - [ ] Full test suite passes: `python -m pytest --override-ini="addopts="` - [ ] `python scripts/check_tenant_filters.py` warning count has decreased (tables with account_id no longer flagged) - [ ] `session_ratings` not touched (already had `account_id NOT NULL` ✓) - [ ] `team_id` columns NOT dropped on script tables, target_lists (deferred cleanup) - [ ] CI passes on `feat/tenant-isolation-phase-1` branch - [ ] Gate verification query run against **production DB** (VPS SSH) and returns all zeros