Files
resolutionflow/docs/superpowers/plans/2026-04-09-tenant-isolation-phase-1.md
2026-04-09 04:58:24 +00:00

2528 lines
89 KiB
Markdown

# 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/<hash>_add_account_id_core_sessions.py` | Create | 1 |
| `backend/alembic/versions/<hash>_add_account_id_ai_branching.py` | Create | 2 |
| `backend/alembic/versions/<hash>_add_account_id_step_ratings.py` | Create | 3 |
| `backend/alembic/versions/<hash>_add_account_id_user_personalization.py` | Create | 4 |
| `backend/alembic/versions/<hash>_add_account_id_psa_notifications.py` | Create | 5 |
| `backend/alembic/versions/<hash>_add_account_id_maintenance.py` | Create | 6 |
| `backend/alembic/versions/<hash>_add_account_id_script_tables.py` | Create | 7 |
| `backend/alembic/versions/<hash>_add_account_id_target_lists.py` | Create | 8 |
| `backend/alembic/versions/<hash>_create_global_content_tables.py` | Create | 9 |
| `backend/alembic/versions/<hash>_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/<hash>_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: <GENERATED — keep as-is from file>
Revises: b8d2f4a6c091
Create Date: <keep as-is>
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '<GENERATED>'
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 -> <new_hash>, 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 <noreply@anthropic.com>"
```
---
## 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/<hash>_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: <GENERATED>
Revises: <HASH_FROM_TASK_1>
Create Date: <keep>
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '<GENERATED>'
down_revision: Union[str, None] = '<HASH_FROM_TASK_1>'
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 `<HASH_FROM_TASK_1>` 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 <noreply@anthropic.com>"
```
---
## 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/<hash>_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: <GENERATED>
Revises: <HASH_FROM_TASK_2>
Create Date: <keep>
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '<GENERATED>'
down_revision: Union[str, None] = '<HASH_FROM_TASK_2>'
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 <noreply@anthropic.com>"
```
---
## Task 4: Group 4 — User personalization
**Tables:** `user_folders`, `user_pinned_trees`
**Backfill:** `user_id → users.account_id`
**Files:**
- Create: `backend/alembic/versions/<hash>_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: <GENERATED>
Revises: <HASH_FROM_TASK_3>
Create Date: <keep>
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '<GENERATED>'
down_revision: Union[str, None] = '<HASH_FROM_TASK_3>'
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 <noreply@anthropic.com>"
```
---
## 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/<hash>_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: <GENERATED>
Revises: <HASH_FROM_TASK_4>
Create Date: <keep>
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '<GENERATED>'
down_revision: Union[str, None] = '<HASH_FROM_TASK_4>'
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 <noreply@anthropic.com>"
```
---
## 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/<hash>_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: <GENERATED>
Revises: <HASH_FROM_TASK_5>
Create Date: <keep>
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '<GENERATED>'
down_revision: Union[str, None] = '<HASH_FROM_TASK_5>'
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 <noreply@anthropic.com>"
```
---
## 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/<hash>_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: <GENERATED>
Revises: <HASH_FROM_TASK_6>
Create Date: <keep>
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '<GENERATED>'
down_revision: Union[str, None] = '<HASH_FROM_TASK_6>'
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 <noreply@anthropic.com>"
```
---
## 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/<hash>_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: <GENERATED>
Revises: <HASH_FROM_TASK_7>
Create Date: <keep>
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '<GENERATED>'
down_revision: Union[str, None] = '<HASH_FROM_TASK_7>'
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 <noreply@anthropic.com>"
```
---
## 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/<hash>_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: <GENERATED>
Revises: <HASH_FROM_TASK_8>
Create Date: <keep>
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 = '<GENERATED>'
down_revision: Union[str, None] = '<HASH_FROM_TASK_8>'
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 <noreply@anthropic.com>"
```
---
## 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/<hash>_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: <GENERATED>
Revises: <HASH_FROM_TASK_9>
Create Date: <keep>
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 = '<GENERATED>'
down_revision: Union[str, None] = '<HASH_FROM_TASK_9>'
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 <noreply@anthropic.com>"
```
---
## 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