61 Commits

Author SHA1 Message Date
Claude
1c6e22ceb3 docs: update CHANGELOG with tenant isolation Phase 1 and recent fixes
Added comprehensive documentation of Tenant Isolation Phase 1 including RLS implementation,
database role separation, admin database isolation, account_id backfill, and global content
separation. Also documented recent bug fixes including non-default tree handling.

https://claude.ai/code/session_01CwER1BcCEGkdUNLRR3fqc6
2026-04-10 10:46:04 +00:00
chihlasm
8292e6ec65 fix: handle non-default, no-team trees in global content migration
Migration 019 only backfills trees with team_id IS NOT NULL.
Migration 3a40fe11b427 only covered is_default=TRUE trees.
Trees with team_id=NULL and is_default=FALSE (e.g. inactive test trees,
pre-team-system content) fell through both passes and triggered the NULL
guard.

Add two new UPDATE steps after the is_default pass:
1. Assign remaining trees to their author's account (if author has one)
2. Final fallback to PLATFORM_ACCOUNT_ID for any still-NULL rows

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 05:21:26 +00:00
chihlasm
20bd428d83 Merge pull request #133 from resolutionflow/feat/tenant-isolation-phase-1
feat: Phase 1 tenant isolation — add account_id to all tenant tables
2026-04-10 00:57:53 -04:00
chihlasm
b9da0e7107 chore: resolve merge conflicts with main
- deps.py: keep require_tenant_context + require_admin_db (RLS deps);
  drop unused get_tenant_context stub from Phase 0
- categories.py: keep both PLATFORM_ACCOUNT_ID and tenant_filter imports
  (body uses both)
- tenant-isolation spec: keep main's resolved TargetList/teams audit answers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 04:57:39 +00:00
chihlasm
8f044849d4 fix: get_tree returns 404 (not 403) for inaccessible trees — don't leak resource existence
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 04:17:31 +00:00
chihlasm
14304be383 fix: correct RLS test fixtures — tree_structure NOT NULL, tree_tags schema, session-scoped set_config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 04:15:41 +00:00
chihlasm
a5c5eb6cc3 fix: convert DATABASE_URL_SYNC from property to overridable field for Alembic superuser URL
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 04:03:32 +00:00
chihlasm
c4f919f3a5 feat: migration — enable RLS on trees, tags, categories, psa_connections, flow_proposals
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 04:02:10 +00:00
chihlasm
8de6ee7aa4 feat: migration — create resolutionflow_app and resolutionflow_admin DB roles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 03:59:28 +00:00
chihlasm
83ad2e0661 feat: migrate admin endpoints to get_admin_db (BYPASSRLS) before RLS switch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 03:57:18 +00:00
chihlasm
ce4056c6b9 test: add failing RLS isolation tests (green after Task 10 migration + Task 11 URL switch)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 03:54:42 +00:00
chihlasm
9d60b9a244 feat: apply require_tenant_context to all user-facing routers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 03:52:52 +00:00
chihlasm
df9ecf2d29 feat: add require_tenant_context and require_admin_db dependencies
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 03:50:59 +00:00
chihlasm
b0e5f12897 feat: register RLS transaction-begin listener on app engine at startup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 03:49:49 +00:00
chihlasm
b4f8694f6b feat: add tenant_context module — ContextVar, transaction listener, tenant_filter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 03:48:34 +00:00
chihlasm
6f1becf21f feat: add admin_engine and get_admin_db for BYPASSRLS admin endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 03:46:29 +00:00
chihlasm
acbfb3fb37 feat: add ADMIN_DATABASE_URL setting with fallback to DATABASE_URL
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 03:45:52 +00:00
chihlasm
a394a1d464 fix: replace account_id=None with PLATFORM_ACCOUNT_ID for global content
After migration 174f442795b7 enforces NOT NULL on account_id, all
platform/global content must use the sentinel platform account instead
of NULL. Three categories of fixes:

1. trees.py: is_default trees now get PLATFORM_ACCOUNT_ID (not None)
2. admin_categories.py: global category CRUD now uses PLATFORM_ACCOUNT_ID
3. categories.py, tags.py, step_categories.py: creation endpoints coerce
   None → PLATFORM_ACCOUNT_ID; IS NULL filter queries updated to
   == PLATFORM_ACCOUNT_ID (IS NULL queries returned empty after migration
   backfilled all global rows to the platform account)

Defines PLATFORM_ACCOUNT_ID constant in app/core/service_account.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 18:35:52 +00:00
chihlasm
d2ebc4f182 fix: correct tree tags subquery in template_trees migration
The INSERT into template_trees incorrectly referenced `tags` as a column
on the `trees` table. Tags are a relationship via the `tree_tag_assignments`
join table — there is no direct column. Migration was failing with:

  UndefinedColumn: column "tags" does not exist ... FROM trees

Fixed by replacing COALESCE(tags, '[]') with a correlated subquery that
aggregates tag names from tree_tag_assignments → tree_tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 17:30:05 +00:00
chihlasm
8bcf08ae06 fix: persist account ownership for script templates and generations 2026-04-09 17:18:38 +00:00
Claude
85575839f2 docs: update CHANGELOG with tenant isolation Phase 0 and security fixes
- Add Tenant Isolation Phase 0 (#132) — app-layer filtering, cross-tenant audit, UUID isolation
- Document CRITICAL copilot tree query isolation fix (#131)
- Add AI session search, analytics, category, PSA retry, and task lane fixes
- Note 404 (not 403) responses for cross-tenant access to avoid confirming resource existence

https://claude.ai/code/session_014EUBLi2jHrnzJupcetmdwV
2026-04-09 10:41:21 +00:00
chihlasm
478205c208 fix: platform account fallback for script_templates seeded without team/user
Migration 057 inserts 6 AD script templates with NULL team_id and NULL
created_by. Neither backfill path (created_by→users, team_id→team admin)
could attribute them to an account, causing the verify check to fail.

Fix: pre-create the platform sentinel account (ON CONFLICT DO NOTHING,
safe since 3a40fe11b427 also creates it idempotently) and add a final
fallback UPDATE assigning any remaining NULL script_templates to it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 06:41:00 +00:00
chihlasm
0f33feb6d6 fix: use correlated subquery in psa_post_log backfill to avoid invalid FROM-clause reference
PostgreSQL UPDATE...FROM does not allow the updated table to be
referenced inside the FROM clause's JOIN conditions. Replace the
LEFT JOIN psa_connections with a correlated subquery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 06:31:17 +00:00
chihlasm
034b858fc9 fix: add depends_on 067 to cc214c63aa30 to fix fresh-DB migration order
session_resolution_outputs is created in migration 067 (sequential branch
from 064). On fresh databases, Alembic could run cc214c63aa30 before 067,
causing "table does not exist" errors. depends_on ensures 067 always runs
first regardless of branch traversal order.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 06:20:00 +00:00
chihlasm
b937cb41e4 fix: merge Phase 1 account_id chain with main head to resolve multiple-heads error
Combines the Phase 1 tenant isolation chain (064 → ... → 174f442795b7)
with the main sequential chain (064 → ... → 070) into a single Alembic
head (a9f3b2c1d4e5) so `alembic upgrade head` in the Dockerfile works
without ambiguity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 06:14:04 +00:00
chihlasm
0d475c71ed fix: correct Phase 1 down_revision — chain from 064 not b8d2f4a6c091
b8d2f4a6c091 was NOT the production head. The true head was 064
(064_normalize_script_builder_messages) via the chain:
b8d2f4a6c091 → f0aad74ea51b → 062 → 063 → 064

This caused 'multiple head revisions' on Railway deployment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 06:04:10 +00:00
chihlasm
417fa562ce fix: Task 9 migration — include tags in template_trees INSERT
The tags column was accidentally omitted from the is_default tree copy.
Now uses COALESCE(tags, '[]'::jsonb) to preserve source tree tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 05:34:59 +00:00
chihlasm
42937b24a4 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>
2026-04-09 05:34:32 +00:00
chihlasm
b4b8c67d3b 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>
2026-04-09 05:31:33 +00:00
chihlasm
d24da77604 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>
2026-04-09 05:25:24 +00:00
chihlasm
857e782d14 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>
2026-04-09 05:23:35 +00:00
chihlasm
086c4580f1 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>
2026-04-09 05:20:56 +00:00
chihlasm
0d69474128 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>
2026-04-09 05:19:12 +00:00
chihlasm
b5fdb488b3 feat: Phase 1 Group 4 — add account_id to user_folders and user_pinned_trees
Backfill: user_id → users.account_id

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 05:16:50 +00:00
chihlasm
de5ecf4fb2 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>
2026-04-09 05:15:10 +00:00
chihlasm
2779a41b94 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>
2026-04-09 05:12:18 +00:00
chihlasm
4666c4f6d2 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>
2026-04-09 05:09:14 +00:00
chihlasm
2837c6e4cf docs: add Phase 1 tenant isolation schema migrations implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 04:58:24 +00:00
chihlasm
b3dba57bc5 feat: tenant isolation Phase 0 — app-layer filters, UUID audit, CI gate (#132)
* docs: add tenant data isolation design spec

Complete architecture plan for multi-tenant data isolation across
all layers (PostgreSQL RLS, application-layer filtering, schema
migration, testing strategy, and phased rollout checklist).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: add background job isolation policy to tenant isolation spec

Documents policy for all 5 existing background jobs:
- Knowledge Flywheel and PSA Retry flagged for account_id threading
- Chat Retention already follows correct pattern (model for others)
- Maintenance Schedule Firing needs account_id in queries + Session creation
- AI Conversation Expiry approved as cross-tenant with justification

Adds approved cross-tenant query registry and Phase 2 checklist items.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: add tenant isolation Phase 0 implementation plan

8 tasks covering: CRITICAL copilot hotfix, tenant_filter() helper,
get_tenant_context dependency, analytics/category/AI session gap fixes,
full UUID endpoint audit, TargetList dead code audit, teams orphan
check, and CI grep check for missing tenant filters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add tenant_filter() helper and get_tenant_context dependency

tenant_filter(model, account_id) is the canonical app-layer tenant
scoping expression. Every query on a tenant table must use it.
build_tree_access_filter and build_step_visibility_filter updated
to call tenant_filter() internally for the account_id match.

get_tenant_context is a FastAPI dependency that returns account_id
or raises 403 if the user has no account — prevents raw access to
current_user.account_id and centralises the null check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: scope analytics/flows/{tree_id} to requesting account

Any authenticated user could read flow analytics (session counts,
completion rates, CSAT) for any tree UUID. Now returns 404 if the
tree doesn't belong to the requesting account.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: scope category tree_count to requesting account

tree_count on GET /categories/{id} was including trees from all
accounts, leaking cross-tenant row counts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: restrict AI session search to current user only

Search endpoint used OR(user_id, account_id), exposing other users'
problem_summary and problem_domain within the same account. Sessions
are user-scoped only — cross-user access requires explicit escalation
or sharing. List and search endpoints now behave consistently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: add ownership check and 404 responses to ai-sessions endpoints

Cross-tenant isolation audit found:
- retry-psa-push had NO ownership check (CRITICAL) — any user could retry any session's PSA push
- save_task_lane used db.get() without ownership filter, returned 403 revealing existence
- get_session returned 403 instead of 404 for unauthorized access
- stream_documentation returned 403 instead of 404

All now use query-level user_id filtering and return 404 to avoid revealing existence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-tenant session access

All session endpoints (get, update, complete, scratchpad, variables, export,
ticket-link) now return 404 instead of 403 when a user tries to access
another user's session. This prevents confirming existence of resources
across tenant boundaries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-tenant tree access

get_tree and update_tree now return 404 when a user cannot access a tree
(private tree from another account). Prevents confirming resource existence
across tenant boundaries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-tenant step access

get_step_or_404 now returns 404 when can_view_step or can_edit_step fails,
preventing confirmation of step existence across tenant boundaries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-tenant upload access

get_upload_url and delete_upload now return 404 when the upload belongs to
a different account/user, preventing resource existence confirmation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-tenant share access

revoke_share and create_share now return 404 when the caller is not the
owner, preventing resource existence confirmation across users.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-team tree access in maintenance schedules

_get_tree_or_403 now returns 404 when the user's team does not match,
preventing confirmation of tree existence across teams.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-account tag access

get_tag now returns 404 for account-specific tags that belong to another
account, preventing resource existence confirmation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-account step category access

get_step_category now returns 404 for account-specific categories that
belong to another account, preventing resource existence confirmation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: add cross-tenant isolation tests for Task 6 UUID audit

Tests cover:
- Tree GET/PUT returns 404 for cross-account access
- Session GET returns 404 for cross-user access
- AI session GET returns 404 for cross-user access
- AI session retry-psa-push requires ownership
- Upload URL returns 404 for cross-account access
- Share revoke returns 404 for cross-user access

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 (not 403) for get_documentation cross-user access; add missing Task 6 tests

get_documentation was revealing session existence via 403. Added pre-check
query filtering by session_id AND user_id before calling the engine.

Also add cross-tenant isolation tests for steps, tags, step_categories,
and maintenance_schedules endpoints fixed in Task 6 (TDD was skipped).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address Task 6 quality review — rename helper, restore 403 for intra-account, add docs test

- Rename _get_tree_or_403 → _get_tree_or_404 in maintenance_schedules.py
  (function now raises 404, old name was misleading)
- Restore HTTP 403 for intra-account permission failures in update_tree:
  same-account users who can see a tree but can't edit it got 404 (wrong);
  only cross-account lookups should return 404 to avoid confirming existence
- Apply same 403/404 distinction to update_tree_visibility
- Add test: get_documentation must return 404 for cross-user session access
- Add comment documenting owner-only design for documentation endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: Task 7+8 — TargetList audit, CI tenant-filter grep check

Task 7: TargetList dead code audit
- Found active code references in 12+ files across backend and frontend
  (full CRUD API + frontend page + MaintenanceScheduleSection + BatchLaunchModal)
- Decision: migrate to account_id in Phase 1 (cannot drop)
- DB row count not available from code-server — must verify from VPS SSH
  before Phase 1 migration
- Teams orphan check query documented; must run from VPS SSH before Phase 1
- Results documented in spec Section 9

Task 8: CI tenant-filter enforcement check (warn mode)
- Create backend/scripts/check_tenant_filters.py
  Scans endpoint and service files for select() on tenant tables without
  tenant_filter/account_id/user_id in surrounding context. Currently
  reports 109 warnings (Phase 1 backlog). Exits 0 (warn mode).
- Add Check tenant filter enforcement step to backend CI job
  Add --fail flag after Phase 1 backlog clears to make it blocking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: record Phase 0 audit results — 0 orphaned teams, 0 target_list rows

Both checks confirmed 2026-04-09 from production DB.
Phase 1 migration is safe to proceed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 00:42:19 -04:00
chihlasm
29a9573d6e fix: CRITICAL — scope copilot tree query to current account (#131)
* docs: add tenant data isolation design spec

Complete architecture plan for multi-tenant data isolation across
all layers (PostgreSQL RLS, application-layer filtering, schema
migration, testing strategy, and phased rollout checklist).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: add background job isolation policy to tenant isolation spec

Documents policy for all 5 existing background jobs:
- Knowledge Flywheel and PSA Retry flagged for account_id threading
- Chat Retention already follows correct pattern (model for others)
- Maintenance Schedule Firing needs account_id in queries + Session creation
- AI Conversation Expiry approved as cross-tenant with justification

Adds approved cross-tenant query registry and Phase 2 checklist items.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: add tenant isolation Phase 0 implementation plan

8 tasks covering: CRITICAL copilot hotfix, tenant_filter() helper,
get_tenant_context dependency, analytics/category/AI session gap fixes,
full UUID endpoint audit, TargetList dead code audit, teams orphan
check, and CI grep check for missing tenant filters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: CRITICAL — scope copilot tree query to current account

A user who knew another account's tree UUID could start a copilot
conversation, causing the tree's full node structure, names, and
descriptions to be sent to the AI as part of the system prompt.

Fix: add account_id (or is_default / visibility='public') filter to
the tree SELECT in copilot_service.start_conversation(). Returns 404
for inaccessible trees. Test added in test_tenant_isolation_p0.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 00:41:30 -04:00
chihlasm
56775eca04 docs: add tenant isolation Phase 0 implementation plan
8 tasks covering: CRITICAL copilot hotfix, tenant_filter() helper,
get_tenant_context dependency, analytics/category/AI session gap fixes,
full UUID endpoint audit, TargetList dead code audit, teams orphan
check, and CI grep check for missing tenant filters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:02:19 +00:00
chihlasm
82bb7967d8 docs: add background job isolation policy to tenant isolation spec
Documents policy for all 5 existing background jobs:
- Knowledge Flywheel and PSA Retry flagged for account_id threading
- Chat Retention already follows correct pattern (model for others)
- Maintenance Schedule Firing needs account_id in queries + Session creation
- AI Conversation Expiry approved as cross-tenant with justification

Adds approved cross-tenant query registry and Phase 2 checklist items.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 02:38:55 +00:00
chihlasm
a7dff9e143 docs: add tenant data isolation design spec
Complete architecture plan for multi-tenant data isolation across
all layers (PostgreSQL RLS, application-layer filtering, schema
migration, testing strategy, and phased rollout checklist).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 02:24:38 +00:00
Claude
ba0680ce06 docs: update CHANGELOG with image support, header actions, and design token normalization
- Added image support in Assistant Chat with S3 upload and vision integration
- Moved session lifecycle actions to header bar in AssistantChatPage
- Normalized design system tokens across FlowPilot, AssistantChat, ScriptBuilder
- Fixed 'sorry something went wrong' errors and image display in chat
- Fixed Task Lane stale data and chat ref invalidation race conditions

https://claude.ai/code/session_01LGJSDQqPi3sPWjC6vh9Uyj
2026-04-08 10:40:44 +00:00
chihlasm
290f2be2fd fix: resolve "sorry something went wrong" errors and show images in chat
Three fixes from beta tester session feedback:

1. MCP error handling (backend/app/services/assistant_chat_service.py)
   - The MCP Microsoft Learn integration was catching only BadRequestError.
     Any other error type (APIStatusError, APIConnectionError, timeout) from
     the external MCP server propagated as a 502, causing the generic error.
   - Now catches all Exception types when MCP is active and retries without
     MCP using the stable client.messages.create endpoint.

2. Frontend error UX (frontend/src/pages/AssistantChatPage.tsx)
   - catch {} was silently swallowing all errors and inserting a generic
     assistant message. Now: differentiates 429 (rate limit) vs 502/503
     (AI unavailable), removes the optimistic user message on failure,
     restores the failed message to the input so users can retry without
     retyping, and logs errors to console for debugging.

3. Image attachments visible in chat (frontend/src/components/assistant/ChatMessage.tsx)
   - Uploaded images were sent to the AI correctly but never shown in the
     chat thread. Now captures preview URLs before clearing pendingUploads
     and renders thumbnails above the user bubble, clickable to full size.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:09:16 +00:00
chihlasm
e8e12cc7e5 fix: move session lifecycle actions to header bar in AssistantChatPage
- Add persistent session header with title, status badge, Resolve,
  Escalate, and Update Ticket/Share Update buttons — mirrors
  FlowPilotSessionPage pattern exactly
- Update Ticket label when psa_ticket_id present, Share Update otherwise
- Full mobile support via ⋯ overflow menu (Resolve, Escalate, Update, Pause)
- Strip _(not yet completed)_ markers from stored conversation_messages
  in unified_chat_service to prevent stale task lane items from prior
  turns leaking into new sessions via the AI's re-include instruction
- Add currentChatRef guard to handleResumeNew (was missing unlike handleSend)
- Remove Update/Conclude from chatbar — toolbar is now input utilities only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 06:31:24 +00:00
chihlasm
bf45322c46 Merge pull request #126 from resolutionflow/refactor/dashboard-design-critique
refactor: normalize FlowPilot/Assistant/ScriptBuilder to design system tokens
2026-04-06 20:23:50 -04:00
Michael Chihlas
f45b045943 refactor: resolve merge conflicts — combine main improvements with token normalization
- .gitignore: keep both graphify-out/ entries and main's .gitnexus entry
- ScriptCodeBlock/ScriptPreviewModal: take main's border-border and text-accent-text
  for filename labels; use neutral ghost style for Save button in ScriptCodeBlock;
  use bg-accent (normalized from bg-primary) for Save button in ScriptPreviewModal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:23:36 -04:00
Michael Chihlas
cef853d7ea refactor: normalize FlowPilot/Assistant/ScriptBuilder to design system tokens
Replace hardcoded Tailwind color utilities with semantic CSS variable tokens
across 31 files in the FlowPilot, Assistant Chat, and Script Builder feature
communities — the areas graphify identified as design-system-free.

- text-blue-400 → text-accent, bg-blue-500/10 → bg-accent-dim, border-blue-500/20 → border-accent/20
- text-amber-400 → text-warning, bg-amber-400/10 → bg-warning-dim, border-l-amber-500 → border-l-warning
- text-rose-400/500 → text-danger, bg-rose-500/10 → bg-danger-dim
- text-emerald-400 → text-success, bg-emerald-500/10 → bg-success-dim, border-l-emerald-500 → border-l-success
- bg-white/[0.08] → bg-elevated (opacity hack → semantic surface token)
- bg-gradient-to-r from-blue-500 to-blue-400 → bg-accent (no gradient surfaces)
- bg-[#60a5fa] → bg-accent (hard-coded hex removed)

Also adds graphify-out/ to .gitignore.

Theme resilience: accent color has changed twice in 5 weeks. Semantic tokens
mean the next change is a 1-line edit in index.css, not 110 grep-and-replace.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:20:07 -04:00
chihlasm
87cf874199 fix: invalidate currentChatRef before await in handleNewChat and handleResumeNew
The previous fix (990f044) moved state clears before the createChatSession
await but left currentChatRef.current pointing at the old session during the
entire network call. Any in-flight handleSend/handleTaskSubmit for the old
session would pass the guard (oldId === oldId) and re-apply stale task lane
data to the new empty session.

Setting currentChatRef.current = null before the await ensures in-flight
handlers from the previous session see a mismatch and bail — matching the
same pattern already used correctly in selectChat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:56:10 +00:00
chihlasm
2b53315cc9 Merge pull request #125 from resolutionflow/fix/task-lane-partial-submit
fix: resolve task lane stale state, partial submit, and closure bugs
2026-04-06 16:31:41 -04:00
chihlasm
1811889ed9 chore: update docs and redesign landing page hero
- CLAUDE.md: correct Docker container names, update migration format
  docs (hash IDs now default), fix Node path in Lesson 63, update
  design system values to electric blue accent, add retracted lessons
  note, add GitNexus section
- .gitignore: add .gitnexus
- Landing page: replace animated chat preview with ticket-comparison
  hero layout; remove backdrop-filter from scrolled nav (aligns with
  design system); clean up removed chat animation CSS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:17:47 +00:00
chihlasm
990f04489f fix: prevent TaskLane showing stale data when starting new chat
Three race conditions in AssistantChatPage:

1. handleNewChat cleared showTaskLane/activeQuestions/activeActions
   AFTER the createChatSession await — old lane was visible during
   the network call. Moved clears before the await.

2. handleResumeNew never cleared old TaskLane state at all. Added
   upfront clears before the first await.

3. handleSend and handleTaskSubmit had no stale-session guard. If
   the user switched chats while sendChatMessage was in flight, the
   response would set showTaskLane on the wrong session. Added
   sentForChatId snapshot + currentChatRef guard (same pattern
   already used in selectChat).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:17:39 +00:00
chihlasm
ba815d3ee5 Merge remote-tracking branch 'origin/main' into fix/task-lane-partial-submit 2026-04-06 20:14:45 +00:00
chihlasm
8bd395a0c7 fix: resolve task lane stale state, partial submit, and closure bugs
- Import and call clearTaskState before updating questions/actions in
  handleSend and handleTaskSubmit so new AI tasks always replace stale
  sessionStorage cache instead of being overridden by it
- Include pending (not yet completed) tasks in the AI message on partial
  submit so the AI knows which tasks were left unanswered
- Fix stale closure in TaskLane saveTaskLane useEffect — use refs for
  questions/actions so the debounced backend save always uses current values
- Add responses field to pending_task_lane TypeScript type, removing the
  unsafe double-cast in selectChat
- Instruct the AI to re-surface incomplete tasks unless ≥75% confident
  the information is no longer needed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:53:48 +00:00
Claude
7198c165b2 docs: update CHANGELOG with session documentation overhaul and client communications
Added entries for:
- Session documentation overhaul with reformatted PSA notes, decimal hour display,
  and follow-up recommendations
- Client communication improvements with request_info audience type
- PSA documentation formatting enhancements
- Status update generation improvements
- Option label resolution fix

https://claude.ai/code/session_01GpyJYk4F3eGiJXwsgycChK
2026-04-06 10:35:01 +00:00
chihlasm
58fe3574bf docs: resolve all contract decisions from codex readiness review
Addresses every Red and Yellow item from the codex review:
- Canonical handoff: ResolutionOutputGenerator is the source of truth
- AI vs manual authority: manual edits win, AI never overwrites
- evidence_items: full-list replacement, frontend is merge authority
- TaskLane persistence: lifted into hook, StepsPanel is presentation-only
- Quick replies: immediate-send, full-stack contract change
- issue_category + asset_name: free text in v1
- Adds 5 implementation guardrails and Phase 2 gate for triage extraction
- Execution order updated to 37 steps with persistence extraction step

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:41:43 +00:00
chihlasm
63a84be921 docs: merge codex insights into claude super plan
Adds key architectural choices summary, assumptions section,
sidebar visual demotion (F9), message click-to-expand in compact
log, and backend-first rationale from the codex plan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:41:43 +00:00
chihlasm
75971d8b97 docs: add MSP assistant harness super plan (claude synthesis)
Merges MSP_Assistant_Harness_Implementation_Plan.docx with the
brainstorming design spec into a single executable plan. Resolves
all open questions from the original docx, expands scope to include
backend changes, and adds a 35-step phased execution order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:41:43 +00:00
chihlasm
7998dd237d docs: add MSP assistant harness cockpit design spec
Design spec for evolving /assistant into a live triage cockpit.
Covers layout decisions (stacked zones, drag-resizable split),
incident header (labelled fields, AI-inferred + editable),
work zone (steps checklist + FlowPilot Asks + What We Know),
conclude modal redesign, and all required backend changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:41:43 +00:00
chihlasm
f4143e52a1 feat: overhaul session documentation, PSA notes, and client communications
- Reformat PSA resolution/escalation notes: clean single-line header,
  steps with engineer responses inline, remove duplicate timing blocks,
  remove AI confidence section, add follow-up recommendations
- Standardize time display to decimal hours (e.g. 0.25 hrs) across all
  note formatters and status update context
- Add follow_up_recommendations to SessionDocumentation schema and
  surface in SessionDocView; extracted from resolution suggestion steps
- Add _build_what_we_know() helper: uses session.evidence_items when
  cockpit branch merges, falls back to deriving findings from steps
- Fix option label lookup in generate_status_update (was passing raw
  machine values to AI instead of human-readable labels)
- Add 'What We Know' section to status update ticket notes prompt
- Improve _build_session_context in resolution_output_generator to
  include intake text and full step details instead of truncated chat
- Add request_info audience type: client-facing information request
  that skips the length step and generates a numbered question list
- Improve client_update and email_draft prompts with per-context
  guidance (status/resolution/escalation) and fix escalation subject
  line from 'Specialist Review' to 'Specialist Assistance'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:18:31 +00:00
116 changed files with 9865 additions and 764 deletions

View File

@@ -47,6 +47,11 @@ jobs:
- name: Install dependencies
run: pip install -r backend/requirements.txt -r backend/requirements-dev.txt
- name: Check tenant filter enforcement
run: cd backend && python scripts/check_tenant_filters.py
# Warn mode only (exits 0). Switch to --fail after Phase 1 backlog clears.
# See: docs/superpowers/specs/2026-04-09-tenant-data-isolation-design.md Section 3f
- name: Run tests with coverage
run: cd backend && python -m pytest --override-ini="addopts=" --cov=app --cov-report=term-missing --cov-report=json:coverage.json --cov-fail-under=50

5
.gitignore vendored
View File

@@ -233,3 +233,8 @@ package.json
package-lock.json
.worktrees/
.gstack/
.gitnexus
# graphify knowledge graph outputs
graphify-out/
.graphify_python

View File

@@ -8,19 +8,42 @@ All notable changes to ResolutionFlow are documented here.
- Tree Templates + Import/Export marketplace (#66)
- Recurring Issue Detection — client-specific pattern alerts (#60)
- Step Feedback Flag — "This Step is Wrong" reporting (#58)
- **Tenant Isolation Phase 0** — multi-tenant data isolation (#132) with app-layer filtering helpers (`tenant_filter()`, `get_tenant_context`), cross-tenant access audit (analytics, categories, AI sessions, trees), UUID endpoint isolation with 404 responses for unauthorized access, ownership checks on all sensitive operations, and CI grep gate for missing tenant filters
- **Tenant Isolation Phase 1** — PostgreSQL Row-Level Security (RLS) enforcement across all core tables (trees, tags, categories, psa_connections, flow_proposals) with database role separation (`resolutionflow_app` for user operations, `resolutionflow_admin` with BYPASSRLS for admin endpoints), admin database engine isolation, tenant context via `ContextVar` with automatic transaction-scoped enforcement, `account_id` column backfill on 35+ tables (sessions, AI branching, PSA, notifications, scripts, targets, folders), global content separation via platform account, fresh-DB migration order fixes
- **Script Library default view** — "All Scripts" tab now displays all accessible scripts (team + library)
- **Session documentation overhaul** — reformatted PSA resolution/escalation notes with cleaner headers, inline engineer responses, decimal hour display (0.25 hrs), follow-up recommendations, and improved "What We Know" section from evidence items
- **Client communication improvements** — new `request_info` audience type for client-facing information requests, improved status update and email draft prompts with per-context guidance
- **Image support in Assistant Chat** — paste/attach images in chat input, uploaded to S3, resized for vision model, displayed in conversation history
### Changed
- **Edit Procedure page** — layout overhaul and color system refinements for better visual hierarchy
- **Flows sidebar navigation** — collapsed to reduce visual noise; session recovery removed from library view
- **Account settings page** — audit fixes for improved consistency and usability
- **PSA documentation formatting** — removed duplicate timing blocks and AI confidence sections; added client-facing communication context guidance
- **Status update generation** — fixed option label lookup to use human-readable labels instead of machine values
- **Assistant Chat session actions** — moved Pause/Resume/Close actions from action bar to page header for consistency with FlowPilot
- **Design system token normalization** — unified FlowPilot, AssistantChat, and ScriptBuilder components to use consistent design tokens
- **Tenant data boundaries** — all session and tree endpoints now return 404 (not 403) for cross-tenant access attempts to avoid confirming resource existence
- **Admin database routing** — privileged operations (analytics, user management) now bypass RLS via dedicated admin engine
### Fixed
- **CRITICAL: Copilot tree query isolation** (#131) — user could access any tree UUID if known, exposing full tree structure to AI. Now scoped to current account with 404 for inaccessible trees.
- **AI session search isolation** — search endpoint leaked other users' sessions via OR(user_id, account_id). Now restricted to current user only.
- **Analytics endpoint isolation** — GET `/analytics/flows/{tree_id}` exposed session counts for any tree UUID. Now returns 404 if tree doesn't belong to requesting account.
- **Category tree counts** — cross-tenant row count leakage via tree_count field in GET `/categories/{id}`. Now scoped to requesting account.
- **PSA retry ownership check** — retry-psa-push had no ownership validation (CRITICAL). Now validates user ownership before allowing retry.
- **Task Lane save operation** — invalid task_lane_item UUIDs returned 403 revealing existence. Now returns 404 and uses query-level filtering.
- Dark text rendering on blue accent step-number badges across all flow types
- Script Library tab ownership filter now preserved across category and search changes
- Race conditions in script builder session creation and slug generation
- Stale async results in Assistant Chat (selectChat) no longer clobber new session task lane
- Sentry DSN hardcoded fallback removed — now uses environment variable only
- Option label resolution in status update context generation
- "Sorry something went wrong" errors in chat when rendering unsupported message types
- Task Lane stale data when creating new chat or resuming from concluded session
- Chat ref invalidation race condition between handleNewChat and async data loads
- Images now properly display in chat message history instead of blank placeholders
- Non-default, no-team trees now properly handled in global content migration
---

156
CLAUDE.md
View File

@@ -1,6 +1,6 @@
# CLAUDE.md - Patherly / ResolutionFlow Project Context
> **Last Updated:** March 27, 2026
> **Last Updated:** April 6, 2026
---
@@ -16,7 +16,8 @@
| Context | Name Used |
|---------|-----------|
| Repository / directory / database / Docker | `patherly` / `patherly_postgres` |
| Repository / directory / database | `patherly` (internal name) |
| Docker containers | `resolutionflow_postgres`, `resolutionflow_frontend`, `resolutionflow_backend` |
| Backend, frontend UI, production URLs | **ResolutionFlow** |
- **Design system:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — THE source of truth for all design decisions
@@ -44,7 +45,7 @@
- **Phase:** Go-to-Market Validation (Pre-PMF)
- **Backend:** Complete (55+ API endpoints, 100+ integration tests)
- **Frontend:** Core features complete, Tree Editor functional
- **Database:** PostgreSQL with Docker, 98 migrations
- **Database:** PostgreSQL with Docker, 101 migrations
- **Detailed status:** [CURRENT-STATE.md](CURRENT-STATE.md)
### What's In Progress
@@ -96,7 +97,7 @@ patherly/
│ │ ├── services/knowledge_flywheel.py # AI session analysis → flow proposals
│ │ ├── services/knowledge_flywheel_scheduler.py # APScheduler job for batch analysis
│ │ └── services/knowledge_gap_service.py # Weak options & escalation signal detection
│ ├── alembic/ # Database migrations (001-029+)
│ ├── alembic/ # Database migrations (001-070 sequential, then hash IDs)
│ ├── scripts/ # seed_data.py, seed_trees.py
│ └── tests/ # pytest integration tests
├── frontend/
@@ -188,8 +189,8 @@ Official ConnectWise developer guides live in `docs/connectwise/best-practices/`
## Development Commands
```powershell
# Start PostgreSQL
docker start patherly_postgres
# Start PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
docker start resolutionflow_postgres
# Backend (from backend/)
source venv/bin/activate # Linux/Mac
@@ -203,21 +204,19 @@ npm run dev
pytest --override-ini="addopts="
# First time only: create test database
docker exec -it patherly_postgres psql -U postgres -c "CREATE DATABASE patherly_test;"
docker exec -it resolutionflow_postgres psql -U postgres -c "CREATE DATABASE resolutionflow_test;"
# Frontend build (IMPORTANT: stricter than tsc --noEmit — always use as final check)
cd frontend && npm run build
# Database migrations
cd backend && alembic upgrade head
alembic revision --autogenerate -m "Description" --rev-id=NNN # NNN = next sequential number
# IMPORTANT: Migrations use sequential 3-digit IDs (001, 002, ..., 068, 069).
# Check the latest: ls backend/alembic/versions/ | grep -E '^\d{3}_' | sort | tail -1
# The revision ID and filename prefix MUST match (e.g., revision="068", file=068_description.py).
# down_revision MUST point to the previous sequential number. Never use hex hash IDs for new migrations.
alembic revision --autogenerate -m "Description"
# Sequential 3-digit IDs (001070) were used historically. New migrations use Alembic's default hex hash IDs.
# Do NOT pass --rev-id — let Alembic generate the hash automatically.
# Access PostgreSQL
docker exec -it patherly_postgres psql -U postgres -d patherly
# Access PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
# Seed data
cd backend && pip install httpx && python -m scripts.seed_trees
@@ -292,7 +291,7 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**62. Playwright strict mode — scope selectors to avoid ambiguity:** Step titles appear in both the sidebar checklist and main content heading. Use `getByRole('heading', { name })` for the main content, or scope with `page.locator('.animate-scale-in')` for command palette items. `getByText()` frequently matches multiple elements due to the sidebar + main content layout.
**63. Node 20 required for frontend builds:** Vite 7+ requires Node 20.19+. The system Node may be v18; use nvm: `export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh" && nvm use 20`. For direct binary access without nvm sourcing: `PATH="/home/michaelchihlas/.nvm/versions/node/v20.19.0/bin:$PATH"`.
**63. Node 20 required for frontend builds:** Vite 7+ requires Node 20.19+. The system Node may be v18; use nvm: `export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh" && nvm use 20`. For direct binary access without nvm sourcing: `PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH"`.
**64. PostHog product analytics:** Initialized via `PostHogProvider` in `main.tsx` with explicit `posthog.init()` + `client` prop pattern. Event helpers in `lib/analytics.ts` — use `analytics.eventName(props)` to track. `identifyUser()` called in `authStore.fetchUser()`, `resetAnalytics()` on logout. Env vars: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`. Autocapture enabled.
@@ -332,7 +331,7 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**82. `bun` requires PATH setup on devserver01:** `export BUN_INSTALL="$HOME/.bun" && export PATH="$BUN_INSTALL/bin:$PATH"`. The gstack browse binary and Playwright need this. Chromium system deps: `libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2`.
**83. FlowPilot ActionBar is `position: fixed; bottom: 0`:** Any UI element placed in normal document flow below the session content will be hidden behind it. New fixed-position elements (like the message bar) must use `bottom: 68px` (action bar height) and the same `left: var(--sidebar-w)` pattern. The conversation column uses `pb-32` for clearance.
**83. ~~FlowPilot ActionBar fixed bottom~~ (Superseded by Lesson 93):** Actions moved to the page header. `FlowPilotActionBar` component exists but is no longer used in the main session flow. The only fixed-bottom element is the message input.
**84. AI session `abandoned` status is fully wired:** `POST /ai-sessions/{id}/abandon` sets status to `abandoned` with optional `reason` param. Frontend: `aiSessionsApi.abandonSession()`, `useFlowPilotSession().abandonSession()`, "Close" button in `FlowPilotActionBar`. Redirects to `/sessions` after closing.
@@ -344,6 +343,7 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**88. Charcoal palette — sidebar-darkest approach:** Sidebar `#0e1016`, page `#16181f`, cards `#1e2028`, borders `#2a2e3a`. This gives more contrast range than true-dark. All colors via CSS variables in `index.css` `@theme` block. Accent is electric blue (#60a5fa), not orange or cyan.
*(Lessons 8991 were retracted.)*
**92. `tsc -b` in Dockerfile is stricter than `npx tsc --noEmit`:** The production build (`tsc -b && vite build`) enforces `noUnusedLocals` and `noUnusedParameters` as hard errors. After any refactor that moves logic between components or removes features, trace every import and destructured prop to remove orphans. IDE warnings (yellow squiggles) flag these — check them before pushing.
@@ -353,7 +353,7 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**95. Image upload → AI vision pipeline:** Paste/attach images → upload to Railway S3 bucket via `uploadsApi.upload()` → send `upload_ids` with chat message → backend fetches from S3 via `storage_service.download_file()` → resized via `storage_service.resize_image_for_vision()` (Pillow, 1568px max, PNG→JPEG) → base64-encoded → sent as Claude multimodal content blocks. Max 3 images/message. Images are NOT stored in conversation history (text-only). Vision helpers live in `storage_service.py`.
**96. `bg-accent` is ember orange — never use for code/kbd elements:** In Tailwind v4, `bg-accent` maps to `--color-accent: #f97316`. Use `bg-code` for code blocks, `bg-white/[0.12] border border-white/[0.06]` for inline code/badges, `bg-white/[0.08]` for kbd shortcuts. Orange is reserved for interactive elements only (buttons, active nav, links).
**96. `bg-accent` is electric blue — never use for code/kbd elements:** In Tailwind v4, `bg-accent` maps to `--color-accent: #60a5fa` (dark) / `#2563eb` (light). Use `bg-code` for code blocks, `bg-white/[0.12] border border-white/[0.06]` for inline code/badges, `bg-white/[0.08]` for kbd shortcuts. Blue accent is reserved for interactive elements only (buttons, active nav, links). Ember orange (#f97316) is deprecated — do not use.
**97. Railway Object Storage (S3 bucket) is provisioned:** Bucket `resolutionflow-uploads` on Railway canvas. Variables: `STORAGE_ENDPOINT`, `STORAGE_ACCESS_KEY`, `STORAGE_SECRET_KEY`, `STORAGE_BUCKET_NAME`, `STORAGE_REGION` — mapped via variable references on the `patherly` backend service. Accessed via boto3 in `storage_service.py`. Pillow (`Pillow>=10.0.0`) + `libjpeg-dev`/`zlib1g-dev` in Dockerfile for image resize.
@@ -390,16 +390,16 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**Source of truth:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — always read this before making visual or UI decisions.
- **Theme:** Flat, high-contrast dark theme (Sentry/PostHog-inspired). No glass morphism, no backdrop blur, no ambient orbs, no gradient backgrounds on surfaces. Light mode planned.
- **Backgrounds:** `bg-page` (`#1a1c23`), `bg-sidebar` (`#10121a`), `bg-card` (`#22252e`), `bg-elevated` (`#2e3140`)
- **Cards:** `bg-card` with 1px `border-default` (`#2e3240`), 8px radius. No shadows, no blur, no gradients. Hover: `border-hover` (`#3d4252`)
- **Buttons:** Primary: solid `accent` (#f97316), white text, 5px radius. Ghost: transparent + 1px border, hover `bg-elevated`
- **Inputs:** `bg-input` (`#282b35`) with 1px `border-default`, 5px radius. Focus: `border-color: accent` + `box-shadow: 0 0 0 2px accent-dim`
- **Text:** `text-heading` (`#f0f2f5`) → `text-primary` (`#e2e5eb`) → `text-muted-foreground` (`#848b9b`) → `text-muted` (`#4f5666`). NEVER use `text-secondary` — in Tailwind v4 it maps to a surface color (#2e3140), not a text color.
- **Borders:** `border-default` (`#2e3240`), `border-hover` (`#3d4252`)
- **Functional colors:** `#34d399` (success), `#eab308` (warning), `#f87171` (danger) — each with `-dim` variant at 10% opacity
- **Accent:** Ember orange `#f97316` — used sparingly (≤5% of UI). `accent-dim` = `rgba(249,115,22,0.10)`, `accent-text` = `#fdba74`
- **Deprecated:** Do NOT use `glass-card`, `glass-stat`, `bg-gradient-brand`, `text-gradient-brand`, `backdrop-filter: blur()`, ambient orbs, purple gradients, or cyan accent (`#22d3ee`)
- **Theme:** Flat, high-contrast dark theme (Sentry/PostHog-inspired). No glass morphism, no backdrop blur, no ambient orbs, no gradient backgrounds on surfaces. Light mode fully specified (v6).
- **Backgrounds:** `bg-page` (`#16181f`), `bg-sidebar` (`#0e1016`), `bg-card` (`#1e2028`), `bg-elevated` (`#2a2d38`)
- **Cards:** `bg-card` with 1px `border-default` (`#2a2e3a`), 8px radius. No shadows, no blur, no gradients. Hover: `border-hover` (`#3d4252`)
- **Buttons:** Primary: solid `accent` (#60a5fa dark / #2563eb light), white text, 5px radius. Ghost: transparent + 1px border, hover `bg-elevated`
- **Inputs:** `bg-input` (`#252830`) with 1px `border-default`, 5px radius. Focus: `border-color: accent` + `box-shadow: 0 0 0 2px accent-dim`
- **Text:** `text-heading` (`#f0f2f5`) → `text-primary` (`#e2e5eb`) → `text-muted-foreground` (`#848b9b`) → `text-muted` (`#4f5666`). NEVER use `text-secondary` — in Tailwind v4 it maps to a surface color, not a text color.
- **Borders:** `border-default` (`#2a2e3a`), `border-hover` (`#3d4252`)
- **Functional colors:** `#34d399` (success), `#fbbf24` (warning/amber), `#f87171` (danger), `#67e8f9` (info/cyan) — each with `-dim` variant at 10% opacity
- **Accent:** Electric blue `#60a5fa` (dark) / `#2563eb` (light) — used sparingly (≤5% of UI). `accent-dim` = `rgba(96,165,250,0.10)`, `accent-text` = `#93c5fd`
- **Deprecated:** Do NOT use `glass-card`, `glass-stat`, `bg-gradient-brand`, `text-gradient-brand`, `backdrop-filter: blur()`, ambient orbs, purple gradients, ember orange (`#f97316`), or cyan (`#22d3ee`) as accent — cyan is now the info color only
---
@@ -518,3 +518,105 @@ When a feature, fix, or significant piece of work is finished and merged/committ
| Bugs & Fixes | CLAUDE.md → Critical Lessons Learned section |
| Design System | [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) |
| Dev Environment | [DEV-ENV.md](DEV-ENV.md) — 46.202.92.250 setup, Docker, CORS, networking |
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **resolutionflow** (14787 symbols, 31366 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
## When Debugging
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/resolutionflow/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
## Tools Quick Reference
| Tool | When to use | Command |
|------|-------------|---------|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/resolutionflow/context` | Codebase overview, check index freshness |
| `gitnexus://repo/resolutionflow/clusters` | All functional areas |
| `gitnexus://repo/resolutionflow/processes` | All execution flows |
| `gitnexus://repo/resolutionflow/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. `gitnexus_impact` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
```bash
npx gitnexus analyze
```
If the index previously included embeddings, preserve them by adding `--embeddings`:
```bash
npx gitnexus analyze --embeddings
```
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->

View File

@@ -0,0 +1,102 @@
"""create_db_roles
Revision ID: 0b470d9e6cf1
Revises: a9f3b2c1d4e5
Create Date: 2026-04-10 03:58:10.207919
"""
import os
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision: str = '0b470d9e6cf1'
down_revision: Union[str, None] = 'a9f3b2c1d4e5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Passwords from env vars. For local dev, defaults are sufficient.
# For production (Railway), set DB_APP_ROLE_PASSWORD and
# DB_ADMIN_ROLE_PASSWORD as environment variables before running migrations.
# Passwords must not contain single quotes.
app_pw = os.environ.get("DB_APP_ROLE_PASSWORD", "app_secret_change_me")
admin_pw = os.environ.get("DB_ADMIN_ROLE_PASSWORD", "admin_secret_change_me")
# Fetch the current database name dynamically — avoids hardcoding
# (the DB is named 'resolutionflow' in dev, potentially different elsewhere).
conn = op.get_bind()
db_name = conn.execute(text("SELECT current_database()")).scalar()
# ── Application role ────────────────────────────────────────────────────
# Subject to RLS. Used by FastAPI at runtime via DATABASE_URL.
op.execute(f"""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'resolutionflow_app') THEN
CREATE ROLE resolutionflow_app LOGIN PASSWORD '{app_pw}';
ELSE
ALTER ROLE resolutionflow_app LOGIN PASSWORD '{app_pw}';
END IF;
END $$
""")
op.execute(f"GRANT CONNECT ON DATABASE {db_name} TO resolutionflow_app")
op.execute("GRANT USAGE ON SCHEMA public TO resolutionflow_app")
op.execute(
"GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public "
"TO resolutionflow_app"
)
op.execute(
"GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO resolutionflow_app"
)
# Ensure future tables automatically get the same permissions
op.execute(
"ALTER DEFAULT PRIVILEGES IN SCHEMA public "
"GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO resolutionflow_app"
)
op.execute(
"ALTER DEFAULT PRIVILEGES IN SCHEMA public "
"GRANT USAGE, SELECT ON SEQUENCES TO resolutionflow_app"
)
# ── Admin role ──────────────────────────────────────────────────────────
# BYPASSRLS. Used by Alembic (DATABASE_URL_SYNC) and /admin/* endpoints
# (ADMIN_DATABASE_URL) after Task 11.
op.execute(f"""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'resolutionflow_admin') THEN
CREATE ROLE resolutionflow_admin LOGIN PASSWORD '{admin_pw}';
ELSE
ALTER ROLE resolutionflow_admin LOGIN PASSWORD '{admin_pw}';
END IF;
END $$
""")
op.execute("GRANT resolutionflow_app TO resolutionflow_admin")
op.execute("ALTER ROLE resolutionflow_admin BYPASSRLS")
op.execute(f"GRANT CONNECT ON DATABASE {db_name} TO resolutionflow_admin")
def downgrade() -> None:
conn = op.get_bind()
db_name = conn.execute(text("SELECT current_database()")).scalar()
op.execute(
"REVOKE ALL ON ALL TABLES IN SCHEMA public FROM resolutionflow_app"
)
op.execute(
"REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM resolutionflow_app"
)
op.execute(
f"REVOKE CONNECT ON DATABASE {db_name} FROM resolutionflow_app"
)
op.execute(
f"REVOKE CONNECT ON DATABASE {db_name} FROM resolutionflow_admin"
)
op.execute("DROP ROLE IF EXISTS resolutionflow_admin")
op.execute("DROP ROLE IF EXISTS resolutionflow_app")

View File

@@ -0,0 +1,86 @@
"""set NOT NULL on all previously-nullable account_id columns
Revision ID: 174f442795b7
Revises: 3a40fe11b427
Create Date: 2026-04-09 00:00:00.000000
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 = '174f442795b7'
down_revision: Union[str, None] = '3a40fe11b427'
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
new_indexes = [
('tree_embeddings', 'ix_tree_embeddings_account_id'),
('feedback', 'ix_feedback_account_id'),
]
for table, index_name in new_indexes:
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

View File

@@ -0,0 +1,62 @@
"""add account_id to target_lists (keep team_id)
Revision ID: 2c6aabd89bc6
Revises: 78fc200abac1
Create Date: 2026-04-09 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '2c6aabd89bc6'
down_revision: Union[str, None] = '78fc200abac1'
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',
)
# Primary: team_id → team admin user → account_id
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
""")
# Fallback: created_by → users.account_id
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')

View File

@@ -0,0 +1,175 @@
"""create template_trees and platform_steps global content tables
Revision ID: 3a40fe11b427
Revises: 2c6aabd89bc6
Create Date: 2026-04-09 00:00:00.000000
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 = '3a40fe11b427'
down_revision: Union[str, None] = '2c6aabd89bc6'
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),
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),
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'])
# ── Copy is_default=TRUE trees → template_trees ─────────────────────────
# Note: trees.tags is a relationship via tree_tags join table — no direct column.
# Aggregate tag names via a correlated subquery.
op.execute("""
INSERT INTO template_trees
(id, name, description, category, tree_type, tree_structure,
tags, is_active, created_at, updated_at, source_tree_id)
SELECT
gen_random_uuid(), t.name, t.description, t.category, t.tree_type,
t.tree_structure,
COALESCE(
(SELECT jsonb_agg(tt.name ORDER BY tt.name)
FROM tree_tag_assignments ta
JOIN tree_tags tt ON tt.id = ta.tag_id
WHERE ta.tree_id = t.id),
'[]'::jsonb
),
t.is_active,
COALESCE(t.created_at, NOW()), COALESCE(t.updated_at, NOW()), t.id
FROM trees t
WHERE t.is_default = TRUE
""")
# ── Copy 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 platform sentinel 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 remaining trees to their author's account ─────────────────────
# Handles trees with no team_id that aren't is_default (e.g. inactive test
# trees, trees created before the team system existed).
op.execute("""
UPDATE trees
SET account_id = u.account_id
FROM users u
WHERE trees.author_id = u.id
AND trees.account_id IS NULL
AND u.account_id IS NOT NULL
""")
# ── Final fallback: any still-NULL trees go to platform account ───────────
# Covers trees whose author has no account (seeded content, system rows).
op.execute("""
UPDATE trees
SET account_id = '00000000-0000-0000-0000-000000000001'
WHERE account_id IS NULL
""")
# ── Assign global categories/tags/steps to platform account ─────────────
op.execute("""
UPDATE tree_categories
SET account_id = '00000000-0000-0000-0000-000000000001'
WHERE account_id IS NULL
""")
op.execute("""
UPDATE tree_tags
SET account_id = '00000000-0000-0000-0000-000000000001'
WHERE account_id IS NULL
""")
op.execute("""
UPDATE step_categories
SET account_id = '00000000-0000-0000-0000-000000000001'
WHERE account_id IS NULL
""")
op.execute("""
UPDATE step_library
SET account_id = '00000000-0000-0000-0000-000000000001'
WHERE account_id IS NULL
""")
# ── Verify zero NULLs in all 5 tables ───────────────────────────────────
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:
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')

View File

@@ -0,0 +1,77 @@
"""add account_id to AI branching tables
Revision ID: 478c159e5654
Revises: cc214c63aa30
Create Date: 2026-04-09 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '478c159e5654'
down_revision: Union[str, None] = 'cc214c63aa30'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
ai_tables = ('session_branches', 'session_handoffs', 'fork_points', 'ai_session_steps')
# Step 1: ADD COLUMN (nullable)
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
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
""")
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 zero NULLs
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')

View File

@@ -0,0 +1,46 @@
"""add account_id to step_ratings and step_usage_log
Revision ID: 7167e9374b0c
Revises: 478c159e5654
Create Date: 2026-04-09 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '7167e9374b0c'
down_revision: Union[str, None] = '478c159e5654'
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')

View File

@@ -0,0 +1,103 @@
"""add account_id to script_builder_sessions, script_templates, script_generations
Revision ID: 78fc200abac1
Revises: 7f136778f5a8
Create Date: 2026-04-09 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '78fc200abac1'
down_revision: Union[str, None] = '7f136778f5a8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
PLATFORM_ACCOUNT_ID = '00000000-0000-0000-0000-000000000001'
def upgrade() -> None:
# Ensure the platform sentinel account exists before any fallback assignments.
# Migration 3a40fe11b427 also inserts this with ON CONFLICT DO NOTHING — safe.
op.execute(f"""
INSERT INTO accounts (id, name, display_code, created_at, updated_at)
VALUES (
'{PLATFORM_ACCOUNT_ID}',
'ResolutionFlow Platform',
'PLATFORM',
NOW(),
NOW()
)
ON CONFLICT (id) DO NOTHING
""")
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 (nullable created_by)
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: 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 u.account_id IS NOT NULL
AND st.account_id IS NULL
""")
# Final fallback: platform-seeded templates with NULL team_id AND NULL created_by
# (e.g. the 6 AD templates inserted by migration 057) → platform sentinel account
op.execute(f"""
UPDATE script_templates
SET account_id = '{PLATFORM_ACCOUNT_ID}'
WHERE account_id IS NULL
""")
# 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')

View File

@@ -0,0 +1,62 @@
"""add account_id to maintenance_schedules
Revision ID: 7f136778f5a8
Revises: 8aac5b372402
Create Date: 2026-04-09 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '7f136778f5a8'
down_revision: Union[str, None] = '8aac5b372402'
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 (only where tree.account_id is NOT NULL)
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')

View File

@@ -0,0 +1,81 @@
"""add account_id to PSA and notification tables
Revision ID: 8aac5b372402
Revises: a1d2a84b9abb
Create Date: 2026-04-09 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '8aac5b372402'
down_revision: Union[str, None] = 'a1d2a84b9abb'
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
# Note: cannot reference the updated table (ppl) inside the FROM clause JOIN,
# so use a correlated subquery for psa_connections lookup instead.
op.execute("""
UPDATE psa_post_log ppl
SET account_id = COALESCE(
(SELECT account_id FROM psa_connections WHERE id = ppl.psa_connection_id),
u.account_id
)
FROM users u
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')

View File

@@ -0,0 +1,45 @@
"""add account_id to user personalization tables
Revision ID: a1d2a84b9abb
Revises: 7167e9374b0c
Create Date: 2026-04-09 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'a1d2a84b9abb'
down_revision: Union[str, None] = '7167e9374b0c'
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')

View File

@@ -0,0 +1,24 @@
"""merge Phase 1 tenant isolation chain with main head
Revision ID: a9f3b2c1d4e5
Revises: 070, 174f442795b7
Create Date: 2026-04-09 00:00:00.000000
Merge migration: consolidates the Phase 1 account_id chain (cc214c63aa30 → … → 174f442795b7)
with the main sequential chain (… → 070) into a single head so that
`alembic upgrade head` works without ambiguity.
"""
from typing import Sequence, Union
revision: str = 'a9f3b2c1d4e5'
down_revision: Union[str, tuple] = ('070', '174f442795b7')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@@ -0,0 +1,108 @@
"""enable_rls_phase1
Revision ID: c5f48b9890f9
Revises: 0b470d9e6cf1
Create Date: 2026-04-10 04:01:13.043321
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c5f48b9890f9'
down_revision: Union[str, None] = '0b470d9e6cf1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_NULL_UUID = "00000000-0000-0000-0000-000000000000"
_PLATFORM_UUID = "00000000-0000-0000-0000-000000000001"
_CURRENT_ACCOUNT = (
f"COALESCE(NULLIF(current_setting('app.current_account_id', TRUE), ''), "
f"'{_NULL_UUID}')::uuid"
)
def upgrade() -> None:
# ── trees ───────────────────────────────────────────────────────────────
# Extended policy mirrors can_access_tree() in app/core/permissions.py.
# Tenant sees: own rows, platform rows, any default tree, any public tree,
# any gallery-featured tree.
# is_gallery_featured = TRUE is included because /public/templates is a
# no-auth endpoint — no tenant context is set, so gallery trees must pass
# RLS on their own flag rather than relying on account_id or is_public.
# Private/team trees from other accounts are hidden.
op.execute("ALTER TABLE trees ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE trees FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON trees
USING (
account_id = {_CURRENT_ACCOUNT}
OR account_id = '{_PLATFORM_UUID}'::uuid
OR is_default = TRUE
OR is_public = TRUE
OR is_gallery_featured = TRUE
)
""")
# ── tree_tags ────────────────────────────────────────────────────────────
# Own account + platform tags (global tags visible to all tenants).
op.execute("ALTER TABLE tree_tags ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE tree_tags FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON tree_tags
USING (
account_id = {_CURRENT_ACCOUNT}
OR account_id = '{_PLATFORM_UUID}'::uuid
)
""")
# ── tree_categories ──────────────────────────────────────────────────────
op.execute("ALTER TABLE tree_categories ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE tree_categories FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON tree_categories
USING (
account_id = {_CURRENT_ACCOUNT}
OR account_id = '{_PLATFORM_UUID}'::uuid
)
""")
# ── step_categories ──────────────────────────────────────────────────────
op.execute("ALTER TABLE step_categories ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE step_categories FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON step_categories
USING (
account_id = {_CURRENT_ACCOUNT}
OR account_id = '{_PLATFORM_UUID}'::uuid
)
""")
# ── psa_connections ──────────────────────────────────────────────────────
# Tenant-only — PSA credentials must never cross tenant boundaries.
op.execute("ALTER TABLE psa_connections ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE psa_connections FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON psa_connections
USING (account_id = {_CURRENT_ACCOUNT})
""")
# ── flow_proposals ────────────────────────────────────────────────────────
# Tenant-only.
op.execute("ALTER TABLE flow_proposals ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE flow_proposals FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON flow_proposals
USING (account_id = {_CURRENT_ACCOUNT})
""")
def downgrade() -> None:
for table in ["trees", "tree_tags", "tree_categories", "step_categories",
"psa_connections", "flow_proposals"]:
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY")

View File

@@ -0,0 +1,95 @@
"""add account_id to core session tables
Revision ID: cc214c63aa30
Revises: b8d2f4a6c091
Create Date: 2026-04-09 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'cc214c63aa30'
down_revision: Union[str, None] = '064'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = ('067',)
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')

View File

@@ -10,6 +10,8 @@ from app.core.database import get_db
from app.core.security import decode_token
from app.models.user import User
from app.models.plan_limits import PlanLimits
from app.core.tenant_context import set_current_account_id, clear_current_account_id
from app.core.admin_database import get_admin_db # noqa: F401 — re-exported for use in endpoints
# Routes that are allowed even when must_change_password is True
_PASSWORD_CHANGE_ALLOWLIST = {
@@ -190,3 +192,44 @@ async def get_plan_limits_for_user(
"""Get plan limits for the current user's account."""
from app.core.subscriptions import get_user_plan_limits
return await get_user_plan_limits(current_user.account_id, db)
async def require_tenant_context(
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Set per-request tenant context for RLS.
Raises 403 if the authenticated user has no account_id — never falls back
to PLATFORM_ACCOUNT_ID (that would grant platform-scope access to a
malformed account).
Sets the ContextVar that the SQLAlchemy transaction-begin listener reads to
issue set_config('app.current_account_id', …, true) on every transaction.
Applied to every user-facing router. NOT applied to /admin/* routers or
public endpoints (auth, shared, webhooks).
"""
if current_user.account_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account required",
)
token = set_current_account_id(current_user.account_id)
try:
yield
finally:
clear_current_account_id(token)
async def require_admin_db(
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
) -> AsyncSession:
"""Return a BYPASSRLS admin DB session after verifying super_admin role.
Use on /admin/* endpoints that query RLS-protected tables. Replaces
Depends(get_db) on the db parameter of those endpoints.
The current_user dep is still declared separately on the endpoint if
the user object is needed in the handler.
"""
return db

View File

@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.core.admin_database import get_admin_db
from app.core.audit import log_audit
from app.core.config import settings
from app.core.security import get_password_hash, generate_temp_password, create_password_reset_token, decode_token, hash_token
@@ -37,7 +37,7 @@ router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/users", response_model=list[UserResponse])
async def list_users(
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=100),
@@ -74,7 +74,7 @@ def _generate_display_code() -> str:
@router.post("/users", response_model=AdminUserCreateResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
data: AdminUserCreate,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Create a new user with a temporary password (super admin only).
@@ -199,7 +199,7 @@ async def create_user(
@router.get("/users/{user_id}", response_model=UserDetailResponse)
async def get_user(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)]
):
"""Get enriched user details (super admin only)."""
@@ -317,7 +317,7 @@ async def get_user(
async def update_user_role(
user_id: UUID,
role_data: RoleUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)]
):
"""Change user role (super admin only)."""
@@ -349,7 +349,7 @@ async def update_user_role(
async def update_account_role(
user_id: UUID,
data: AccountRoleUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)]
):
"""Change a user's account role (super admin only)."""
@@ -375,7 +375,7 @@ async def update_account_role(
async def update_super_admin_status(
user_id: UUID,
data: dict,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)]
):
"""Promote or demote a user to/from super admin (super admin only)."""
@@ -414,7 +414,7 @@ async def update_super_admin_status(
@router.put("/users/{user_id}/deactivate", response_model=UserResponse)
async def deactivate_user(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)]
):
"""Deactivate a user account (super admin only)."""
@@ -443,7 +443,7 @@ async def deactivate_user(
@router.put("/users/{user_id}/activate", response_model=UserResponse)
async def activate_user(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)]
):
"""Reactivate a user account (super admin only)."""
@@ -467,7 +467,7 @@ async def activate_user(
async def move_user_account(
user_id: UUID,
data: MoveUserAccount,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Move a user to a different account (super admin only)."""
@@ -520,7 +520,7 @@ async def _get_user_subscription(user_id: UUID, db: AsyncSession) -> tuple[User,
async def update_user_plan(
user_id: UUID,
data: SubscriptionPlanUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Change a user's subscription plan (super admin only)."""
@@ -539,7 +539,7 @@ async def update_user_plan(
async def extend_user_trial(
user_id: UUID,
data: ExtendTrialRequest,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Extend or start a trial for a user's subscription (super admin only)."""
@@ -569,7 +569,7 @@ async def extend_user_trial(
async def admin_reset_password(
user_id: UUID,
data: AdminPasswordReset,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Admin-triggered password reset (super admin only).
@@ -640,7 +640,7 @@ async def admin_reset_password(
@router.put("/users/{user_id}/archive", response_model=UserResponse)
async def archive_user(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Archive (soft delete) a user (super admin only)."""
@@ -675,7 +675,7 @@ async def archive_user(
@router.put("/users/{user_id}/restore", response_model=UserResponse)
async def restore_user(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Restore an archived user (super admin only)."""
@@ -700,7 +700,7 @@ async def restore_user(
@router.get("/users/{user_id}/hard-delete-check", response_model=HardDeleteCheckResponse)
async def hard_delete_check(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Check if a user can be hard-deleted (super admin only). Returns blockers."""
@@ -773,7 +773,7 @@ async def hard_delete_check(
@router.delete("/users/{user_id}/hard-delete", status_code=status.HTTP_204_NO_CONTENT)
async def hard_delete_user(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Permanently delete a user (super admin only). User must be archived first."""
@@ -833,7 +833,7 @@ async def hard_delete_user(
@router.post("/invites", status_code=status.HTTP_201_CREATED)
async def admin_create_invite(
data: dict,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Quick-invite a user to an account (super admin only).

View File

@@ -4,25 +4,26 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.database import get_db
from app.core.admin_database import get_admin_db
from app.core.audit import log_audit
from app.models.user import User
from app.models.category import TreeCategory
from app.models.tree import Tree
from app.schemas.admin import GlobalCategoryCreate, GlobalCategoryUpdate, GlobalCategoryResponse
from app.api.deps import require_admin
from app.core.service_account import PLATFORM_ACCOUNT_ID
router = APIRouter(prefix="/admin/categories", tags=["admin-categories"])
@router.get("/global", response_model=list[GlobalCategoryResponse])
async def list_global_categories(
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all global categories (account_id IS NULL)."""
result = await db.execute(
select(TreeCategory).where(TreeCategory.account_id.is_(None)).order_by(TreeCategory.name)
select(TreeCategory).where(TreeCategory.account_id == PLATFORM_ACCOUNT_ID).order_by(TreeCategory.name)
)
categories = result.scalars().all()
@@ -45,36 +46,36 @@ async def list_global_categories(
@router.post("/global", response_model=GlobalCategoryResponse, status_code=status.HTTP_201_CREATED)
async def create_global_category(
data: GlobalCategoryCreate,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Create a global category."""
# Check slug uniqueness for global categories
existing = await db.execute(
select(TreeCategory).where(TreeCategory.slug == data.slug, TreeCategory.account_id.is_(None))
select(TreeCategory).where(TreeCategory.slug == data.slug, TreeCategory.account_id == PLATFORM_ACCOUNT_ID)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Global category with this slug already exists")
category = TreeCategory(name=data.name, slug=data.slug, account_id=None)
category = TreeCategory(name=data.name, slug=data.slug, account_id=PLATFORM_ACCOUNT_ID)
db.add(category)
await log_audit(db, current_user.id, "global_category.create", "category", details={"name": data.name})
await db.commit()
await db.refresh(category)
return GlobalCategoryResponse(id=category.id, name=category.name, slug=category.slug, account_id=None, tree_count=0)
return GlobalCategoryResponse(id=category.id, name=category.name, slug=category.slug, account_id=PLATFORM_ACCOUNT_ID, tree_count=0)
@router.put("/global/{category_id}", response_model=GlobalCategoryResponse)
async def update_global_category(
category_id: UUID,
data: GlobalCategoryUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update a global category."""
result = await db.execute(
select(TreeCategory).where(TreeCategory.id == category_id, TreeCategory.account_id.is_(None))
select(TreeCategory).where(TreeCategory.id == category_id, TreeCategory.account_id == PLATFORM_ACCOUNT_ID)
)
category = result.scalar_one_or_none()
if not category:
@@ -86,7 +87,7 @@ async def update_global_category(
# Check slug uniqueness
existing = await db.execute(
select(TreeCategory).where(
TreeCategory.slug == data.slug, TreeCategory.account_id.is_(None), TreeCategory.id != category_id
TreeCategory.slug == data.slug, TreeCategory.account_id == PLATFORM_ACCOUNT_ID, TreeCategory.id != category_id
)
)
if existing.scalar_one_or_none():
@@ -103,19 +104,19 @@ async def update_global_category(
return GlobalCategoryResponse(
id=category.id, name=category.name, slug=category.slug,
account_id=None, tree_count=tree_count,
account_id=PLATFORM_ACCOUNT_ID, tree_count=tree_count,
)
@router.delete("/global/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_global_category(
category_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Delete (archive) a global category."""
result = await db.execute(
select(TreeCategory).where(TreeCategory.id == category_id, TreeCategory.account_id.is_(None))
select(TreeCategory).where(TreeCategory.id == category_id, TreeCategory.account_id == PLATFORM_ACCOUNT_ID)
)
category = result.scalar_one_or_none()
if not category:

View File

@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.database import get_db
from app.core.admin_database import get_admin_db
from app.models.user import User
from app.models.subscription import Subscription
from app.models.tree import Tree
@@ -16,7 +16,7 @@ router = APIRouter(prefix="/admin/dashboard", tags=["admin-dashboard"])
@router.get("/metrics", response_model=DashboardMetrics)
async def get_dashboard_metrics(
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Get platform overview metrics."""
@@ -45,7 +45,7 @@ async def get_dashboard_metrics(
@router.get("/activity", response_model=list[ActivityEntry])
async def get_dashboard_activity(
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Get recent audit log entries for activity feed."""

View File

@@ -12,7 +12,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_admin
from app.core.database import get_db
from app.core.admin_database import get_admin_db
from app.models.script_template import ScriptTemplate
from app.models.tree import Tree
from app.models.user import User
@@ -66,7 +66,7 @@ def _script_summary(script: ScriptTemplate) -> dict:
@router.get("/featured")
async def list_featured(
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all featured flows and scripts (super admin only)."""
@@ -92,7 +92,7 @@ async def list_featured(
@router.get("/items")
async def list_all_items(
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List ALL flows and scripts with their gallery status (super admin only)."""
@@ -119,7 +119,7 @@ async def list_all_items(
async def toggle_flow_featured(
flow_id: UUID,
body: FeatureToggle,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Toggle is_gallery_featured on a flow (super admin only)."""
@@ -138,7 +138,7 @@ async def toggle_flow_featured(
async def update_flow_sort_order(
flow_id: UUID,
body: SortOrderUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update gallery_sort_order on a flow (super admin only)."""
@@ -157,7 +157,7 @@ async def update_flow_sort_order(
async def toggle_script_featured(
script_id: UUID,
body: FeatureToggle,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Toggle is_gallery_featured on a script (super admin only)."""
@@ -176,7 +176,7 @@ async def toggle_script_featured(
async def update_script_sort_order(
script_id: UUID,
body: SortOrderUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update gallery_sort_order on a script (super admin only)."""

View File

@@ -519,11 +519,15 @@ async def save_task_lane(
_: None = Depends(require_engineer_or_admin),
):
"""Save the current task lane state including user's in-progress responses."""
session = await db.get(AISession, session_id)
result = await db.execute(
select(AISession).where(
AISession.id == session_id,
AISession.user_id == current_user.id,
)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not your session")
payload = {
"questions": [q.model_dump() for q in body.questions],
@@ -762,13 +766,13 @@ async def search_sessions(
limit: int = Query(5, ge=1, le=20),
):
"""Search AI sessions by content using full-text search. Used by Command Palette."""
# Sessions are user-scoped. The list endpoint uses user_id only;
# search must be consistent. Cross-user access requires explicit
# escalation or session sharing — not ambient account membership.
result = await db.execute(
select(AISession)
.where(
or_(
AISession.user_id == current_user.id,
AISession.account_id == current_user.account_id,
),
AISession.user_id == current_user.id,
text("ai_sessions.search_vector @@ plainto_tsquery('english', :q)"),
)
.params(q=q)
@@ -901,7 +905,7 @@ async def get_session(
pkg = session.escalation_package or {}
is_handler = pkg.get("picked_up_by") == str(current_user.id)
if session.user_id != current_user.id and session.escalated_to_id != current_user.id and not is_handler:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
return _build_session_detail(session)
@@ -917,6 +921,18 @@ async def get_documentation(
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get auto-generated documentation for a session."""
# Verify session ownership — owner only. Documentation endpoints require direct
# ownership; escalated_to_id / picked_up_by handlers use get_session (read-only).
# This is consistent with stream_documentation which has the same owner-only check.
result = await db.execute(
select(AISession).where(
AISession.id == session_id,
AISession.user_id == current_user.id,
)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Session not found")
try:
return await flowpilot_engine.get_session_documentation(
session_id=session_id,
@@ -942,13 +958,14 @@ async def stream_documentation(
# Verify session ownership
result = await db.execute(
select(AISession).where(AISession.id == session_id)
select(AISession).where(
AISession.id == session_id,
AISession.user_id == current_user.id,
)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized")
async def event_generator():
try:
@@ -1043,6 +1060,19 @@ async def retry_psa_push_endpoint(
"""Manually retry a failed PSA documentation push."""
from app.models.psa_post_log import PsaPostLog
# Verify the session belongs to the current user
session_result = await db.execute(
select(AISession).where(
AISession.id == session_id,
AISession.user_id == current_user.id,
)
)
if not session_result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found",
)
# Find the latest failed push log for this session
result = await db.execute(
select(PsaPostLog)

View File

@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.api.deps import get_current_active_user
from app.core.filters import tenant_filter
from app.models import User, Session, Tree, SessionRating
from app.schemas.analytics import (
TeamAnalyticsResponse, PersonalAnalyticsResponse, FlowAnalyticsResponse,
@@ -290,8 +291,13 @@ async def get_flow_analytics(
current_user: User = Depends(get_current_active_user),
):
"""Analytics for a specific flow."""
# Verify tree exists
result = await db.execute(select(Tree).where(Tree.id == tree_id))
# Verify tree exists and belongs to the requesting user's account.
result = await db.execute(
select(Tree).where(
Tree.id == tree_id,
tenant_filter(Tree, current_user.account_id),
)
)
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(status_code=404, detail="Flow not found")

View File

@@ -12,6 +12,8 @@ from app.models.user import User
from app.schemas.category import CategoryCreate, CategoryUpdate, CategoryResponse, CategoryListResponse
from app.api.deps import get_current_active_user
from app.core.permissions import can_manage_category, can_create_category
from app.core.service_account import PLATFORM_ACCOUNT_ID
from app.core.filters import tenant_filter
router = APIRouter(prefix="/categories", tags=["categories"])
@@ -47,13 +49,13 @@ async def list_categories(
elif current_user.account_id:
query = query.where(
or_(
TreeCategory.account_id.is_(None), # Global
TreeCategory.account_id == PLATFORM_ACCOUNT_ID, # Global
TreeCategory.account_id == current_user.account_id # User's account
)
)
else:
# User has no account, only show global categories
query = query.where(TreeCategory.account_id.is_(None))
query = query.where(TreeCategory.account_id == PLATFORM_ACCOUNT_ID)
query = query.order_by(TreeCategory.display_order, TreeCategory.name)
@@ -108,10 +110,12 @@ async def get_category(
detail="You don't have access to this category"
)
# Get tree count
# Get tree count — scoped to the requesting account so cross-account
# trees in shared categories are not counted.
count_query = select(func.count(Tree.id)).where(
Tree.category_id == category.id,
Tree.is_active == True
Tree.is_active == True,
tenant_filter(Tree, current_user.account_id),
)
count_result = await db.execute(count_query)
tree_count = count_result.scalar() or 0
@@ -173,7 +177,7 @@ async def create_category(
name=category_data.name,
slug=slug,
description=category_data.description,
account_id=category_data.account_id,
account_id=category_data.account_id if category_data.account_id is not None else PLATFORM_ACCOUNT_ID,
display_order=max_order + 1,
created_by=current_user.id
)

View File

@@ -29,8 +29,8 @@ def _compute_next_run(cron_expression: str, tz_name: str) -> datetime:
return cron.get_next(datetime).astimezone(timezone.utc)
async def _get_tree_or_403(tree_id: UUID, current_user: User, db: AsyncSession) -> "Tree":
"""Fetch tree and verify the current user's team owns it."""
async def _get_tree_or_404(tree_id: UUID, current_user: User, db: AsyncSession) -> "Tree":
"""Fetch tree and verify the current user's team owns it. Raises 404 if not found or access denied."""
result = await db.execute(select(Tree).where(Tree.id == tree_id))
tree = result.scalar_one_or_none()
if not tree:
@@ -38,7 +38,7 @@ async def _get_tree_or_403(tree_id: UUID, current_user: User, db: AsyncSession)
# Super admins can access any tree; regular users must be on the same team
if not getattr(current_user, 'is_super_admin', False):
if tree.team_id != current_user.team_id:
raise HTTPException(status_code=403, detail="Access denied")
raise HTTPException(status_code=404, detail="Tree not found")
return tree
@@ -51,7 +51,7 @@ async def create_schedule(
):
"""Create a cron schedule for a maintenance flow. One per flow."""
# Verify user's team owns the tree
tree = await _get_tree_or_403(data.tree_id, current_user, db)
tree = await _get_tree_or_404(data.tree_id, current_user, db)
if tree.tree_type != "maintenance":
raise HTTPException(status_code=400, detail="Schedules are only supported for maintenance flows")
@@ -94,7 +94,7 @@ async def get_schedule_for_tree(
):
"""Get the schedule for a specific maintenance flow."""
# Verify user's team owns the tree before returning schedule data
await _get_tree_or_403(tree_id, current_user, db)
await _get_tree_or_404(tree_id, current_user, db)
result = await db.execute(
select(MaintenanceSchedule).where(MaintenanceSchedule.tree_id == tree_id)
@@ -122,7 +122,7 @@ async def update_schedule(
raise HTTPException(status_code=404, detail="Schedule not found")
# Verify user's team owns the tree this schedule belongs to
await _get_tree_or_403(schedule.tree_id, current_user, db)
await _get_tree_or_404(schedule.tree_id, current_user, db)
update_fields = data.model_fields_set
was_active = schedule.is_active

View File

@@ -197,6 +197,7 @@ async def create_template(
template = ScriptTemplate(
category_id=data.category_id,
team_id=current_user.team_id,
account_id=current_user.account_id,
created_by=current_user.id,
name=data.name,
slug=slug,
@@ -364,6 +365,7 @@ async def generate_script(
generation = ScriptGeneration(
template_id=template.id,
user_id=current_user.id,
account_id=current_user.account_id,
team_id=current_user.team_id,
session_id=data.session_id,
ai_session_id=data.ai_session_id,

View File

@@ -143,8 +143,8 @@ async def get_session(
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this session"
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
return session
@@ -234,8 +234,8 @@ async def update_session(
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this session"
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
if session.completed_at:
@@ -281,8 +281,8 @@ async def complete_session(
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this session"
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
if session.completed_at:
@@ -319,8 +319,8 @@ async def update_scratchpad(
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this session"
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
session.scratchpad = data.scratchpad
@@ -348,8 +348,8 @@ async def update_session_variables(
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this session"
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
if session.completed_at:
@@ -387,8 +387,8 @@ async def export_session(
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this session"
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
# PDF export — separate path with binary response
@@ -830,8 +830,8 @@ async def link_ticket(
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
if not current_user.is_super_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this session",
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found",
)
# Unlink

View File

@@ -72,8 +72,8 @@ async def create_share(
if session.user_id != current_user.id and not current_user.is_super_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the session owner can create share links"
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
# Require account_id for account-scoped shares
@@ -170,8 +170,8 @@ async def revoke_share(
if share.created_by != current_user.id and not current_user.is_super_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the share creator can revoke it"
status_code=status.HTTP_404_NOT_FOUND,
detail="Share not found"
)
share.is_active = False

View File

@@ -16,6 +16,7 @@ from app.schemas.step_category import (
)
from app.api.deps import get_current_active_user
from app.core.permissions import can_manage_step_category, can_create_step_category
from app.core.service_account import PLATFORM_ACCOUNT_ID
router = APIRouter(prefix="/step-categories", tags=["step-categories"])
@@ -44,13 +45,13 @@ async def list_step_categories(
elif current_user.account_id:
query = query.where(
or_(
StepCategory.account_id.is_(None), # Global
StepCategory.account_id == PLATFORM_ACCOUNT_ID, # Global
StepCategory.account_id == current_user.account_id # User's account
)
)
else:
# User has no account, only show global categories
query = query.where(StepCategory.account_id.is_(None))
query = query.where(StepCategory.account_id == PLATFORM_ACCOUNT_ID)
query = query.order_by(StepCategory.display_order, StepCategory.name)
@@ -94,8 +95,8 @@ async def get_step_category(
# Check access: global categories visible to all, account categories only to account members
if category.account_id and category.account_id != current_user.account_id and not current_user.is_super_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this step category"
status_code=status.HTTP_404_NOT_FOUND,
detail="Step category not found"
)
return StepCategoryResponse(
@@ -155,7 +156,7 @@ async def create_step_category(
name=category_data.name,
slug=slug,
description=category_data.description,
account_id=category_data.account_id,
account_id=category_data.account_id if category_data.account_id is not None else PLATFORM_ACCOUNT_ID,
display_order=max_order + 1,
created_by=current_user.id
)

View File

@@ -47,10 +47,10 @@ async def get_step_or_404(
raise HTTPException(status_code=404, detail="Step not found")
if check_view and not can_view_step(current_user, step):
raise HTTPException(status_code=403, detail="Not authorized to view this step")
raise HTTPException(status_code=404, detail="Step not found")
if check_edit and not can_edit_step(current_user, step):
raise HTTPException(status_code=403, detail="Not authorized to modify this step")
raise HTTPException(status_code=404, detail="Step not found")
return step

View File

@@ -12,6 +12,7 @@ from app.models.user import User
from app.schemas.tag import TagCreate, TagResponse, TagListResponse, TagAssignment
from app.api.deps import get_current_active_user
from app.core.permissions import can_manage_tree_tags, can_create_tag
from app.core.service_account import PLATFORM_ACCOUNT_ID
router = APIRouter(prefix="/tags", tags=["tags"])
@@ -33,13 +34,13 @@ async def list_tags(
if include_account and current_user.account_id:
query = query.where(
or_(
TreeTag.account_id.is_(None), # Global
TreeTag.account_id == PLATFORM_ACCOUNT_ID, # Global
TreeTag.account_id == current_user.account_id # User's account
)
)
else:
# Only show global tags
query = query.where(TreeTag.account_id.is_(None))
query = query.where(TreeTag.account_id == PLATFORM_ACCOUNT_ID)
query = query.order_by(TreeTag.usage_count.desc(), TreeTag.name)
@@ -71,12 +72,12 @@ async def search_tags(
if include_account and current_user.account_id:
query = query.where(
or_(
TreeTag.account_id.is_(None),
TreeTag.account_id == PLATFORM_ACCOUNT_ID,
TreeTag.account_id == current_user.account_id
)
)
else:
query = query.where(TreeTag.account_id.is_(None))
query = query.where(TreeTag.account_id == PLATFORM_ACCOUNT_ID)
query = query.order_by(TreeTag.usage_count.desc(), TreeTag.name).limit(limit)
@@ -105,8 +106,8 @@ async def get_tag(
# Check access: global tags visible to all, account tags only to account members
if tag.account_id and tag.account_id != current_user.account_id and not current_user.is_super_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this tag"
status_code=status.HTTP_404_NOT_FOUND,
detail="Tag not found"
)
return TagResponse.model_validate(tag)
@@ -147,7 +148,7 @@ async def create_tag(
new_tag = TreeTag(
name=tag_data.name,
slug=slug,
account_id=tag_data.account_id,
account_id=tag_data.account_id if tag_data.account_id is not None else PLATFORM_ACCOUNT_ID,
created_by=current_user.id
)
db.add(new_tag)
@@ -206,7 +207,7 @@ async def add_tags_to_tree(
tag_query = select(TreeTag).where(
TreeTag.slug == slug,
or_(
TreeTag.account_id.is_(None), # Global tag
TreeTag.account_id == PLATFORM_ACCOUNT_ID, # Global tag
TreeTag.account_id == tag_account_id # Account tag
)
)
@@ -340,7 +341,7 @@ async def replace_tree_tags(
tag_query = select(TreeTag).where(
TreeTag.slug == slug,
or_(
TreeTag.account_id.is_(None),
TreeTag.account_id == PLATFORM_ACCOUNT_ID,
TreeTag.account_id == tag_account_id
)
)

View File

@@ -29,6 +29,7 @@ from app.core.subscriptions import check_tree_limit, get_account_subscription, g
from app.core.audit import log_audit
from app.core.config import settings
from app.core.tree_validation import can_publish_tree
from app.core.service_account import PLATFORM_ACCOUNT_ID
from app.core.step_sync import sync_steps_from_tree, deactivate_synced_steps_for_tree
from app.services.rag_service import index_tree as rag_index_tree
@@ -391,9 +392,10 @@ async def get_tree(
)
if not tree.is_active or not can_access_tree(current_user, tree):
# Always 404, never 403. A 403 confirms the resource exists.
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this tree"
status_code=status.HTTP_404_NOT_FOUND,
detail="Tree not found"
)
return build_full_tree_response(tree)
@@ -470,7 +472,7 @@ async def create_tree(
tree_structure=tree_data.tree_structure,
intake_form=intake_form_data,
author_id=service_account_id if is_default else current_user.id,
account_id=None if is_default else current_user.account_id,
account_id=PLATFORM_ACCOUNT_ID if is_default else current_user.account_id,
is_public=True if is_default else tree_data.is_public, # Default trees are always public
is_default=is_default,
status=tree_data.status
@@ -610,9 +612,17 @@ async def update_tree(
)
if not can_edit_tree(current_user, tree):
# If the user can see this tree (same account, team visibility), give a 403 with
# a clear message — returning 404 here would be confusing since GET returns 200.
# For truly inaccessible trees (cross-account), return 404 to avoid confirming existence.
if can_access_tree(current_user, tree):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have permission to edit this flow"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only edit your own trees"
status_code=status.HTTP_404_NOT_FOUND,
detail="Tree not found"
)
# Extract tags for separate handling
@@ -1144,9 +1154,17 @@ async def update_tree_visibility(
)
if not can_edit_tree(current_user, tree):
# If the user can see this tree (same account, team visibility), give a 403 with
# a clear message — returning 404 here would be confusing since GET returns 200.
# For truly inaccessible trees (cross-account), return 404 to avoid confirming existence.
if can_access_tree(current_user, tree):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have permission to edit this flow"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only edit your own trees"
status_code=status.HTTP_404_NOT_FOUND,
detail="Tree not found"
)
# Update visibility

View File

@@ -255,9 +255,9 @@ async def get_upload_url(
if upload is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload not found")
# Verify the upload belongs to the user's account
# Verify the upload belongs to the user's account — 404 to avoid revealing existence
if upload.account_id != current_user.account_id and not current_user.is_super_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload not found")
url = storage_service.get_presigned_url(upload.storage_key)
return {"url": url}
@@ -311,9 +311,9 @@ async def delete_upload(
if upload is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload not found")
# Verify ownership
# Verify ownership — 404 to avoid revealing existence
if upload.uploaded_by != current_user.id and not current_user.is_super_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload not found")
# Delete from S3
await storage_service.delete_file(upload.storage_key)

View File

@@ -1,51 +1,89 @@
from fastapi import APIRouter
from app.api.endpoints import auth, trees, sessions, sidebar, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown
from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, admin_feature_flags, admin_settings, admin_categories
from app.api.endpoints import ratings, analytics
from app.api.endpoints import target_lists
from app.api.endpoints import maintenance_schedules
from app.api.endpoints import feedback
from app.api.endpoints import ai_builder
from app.api.endpoints import ai_fix
from app.api.endpoints import ai_chat
from app.api.endpoints import copilot
from app.api.endpoints import assistant_chat
from app.api.endpoints import survey
from app.api.endpoints import admin_survey
from app.api.endpoints import tree_transfer
from app.api.endpoints import ai_suggestions
from app.api.endpoints import kb_accelerator
from app.api.endpoints import beta_signup
from app.api.endpoints import scripts
from app.api.endpoints import integrations
from app.api.endpoints import onboarding
from app.api.endpoints import branding
from app.api.endpoints import supporting_data
from app.api.endpoints import ai_sessions
from app.api.endpoints import flow_proposals
from app.api.endpoints import flowpilot_analytics
from app.api.endpoints import notifications
from app.api.endpoints import public_templates
from app.api.endpoints import admin_gallery
from app.api.endpoints import uploads
from app.api.endpoints import script_builder
from app.api.endpoints import beta_feedback
from app.api.endpoints import session_branches
from app.api.endpoints import session_handoffs
from app.api.endpoints import session_resolutions
from fastapi import APIRouter, Depends
from app.api.deps import require_tenant_context
from app.api.endpoints import (
admin,
admin_audit,
admin_categories,
admin_dashboard,
admin_feature_flags,
admin_gallery,
admin_plan_limits,
admin_settings,
admin_survey,
ai_builder,
ai_chat,
ai_fix,
ai_sessions,
ai_suggestions,
analytics,
assistant_chat,
auth,
beta_feedback,
beta_signup,
branding,
categories,
copilot,
feedback,
flow_proposals,
flowpilot_analytics,
folders,
integrations,
invite,
kb_accelerator,
maintenance_schedules,
notifications,
onboarding,
public_templates,
ratings,
scripts,
script_builder,
session_branches,
session_handoffs,
session_resolutions,
sessions,
shared,
shares,
sidebar,
step_categories,
steps,
supporting_data,
survey,
tags,
target_lists,
tree_markdown,
tree_transfer,
trees,
uploads,
webhooks,
accounts,
)
api_router = APIRouter()
# ---------------------------------------------------------------------------
# Public / unauthenticated endpoints — no tenant context
#
# Note: auth.router contains both public endpoints (register, login,
# forgot-password, reset-password, email/verify) and authenticated endpoints
# (GET/PATCH /me, logout, change-password, email/send-verification).
# The authenticated auth endpoints only query the `users` table, which is
# excluded from Phase 1 RLS. They work correctly without tenant context
# in Phase 1. This will need revisiting in Phase 2 when `users` gets RLS.
# ---------------------------------------------------------------------------
api_router.include_router(auth.router)
api_router.include_router(trees.router)
api_router.include_router(sidebar.router)
api_router.include_router(sessions.router)
api_router.include_router(invite.router)
api_router.include_router(categories.router)
api_router.include_router(tags.router)
api_router.include_router(folders.router)
api_router.include_router(step_categories.router)
api_router.include_router(steps.router)
api_router.include_router(shared.router) # Public share links (no auth)
api_router.include_router(beta_signup.router)
api_router.include_router(webhooks.router) # Stripe webhook receiver
api_router.include_router(public_templates.router) # Public gallery (no auth, rate-limited)
# ---------------------------------------------------------------------------
# Admin endpoints — super_admin only
# admin_categories, admin_gallery, admin_dashboard, admin query Phase 1 RLS
# tables and MUST use get_admin_db (migrated in Task 8). The remaining admin
# endpoints (admin_audit, admin_plan_limits, admin_feature_flags,
# admin_settings, admin_survey) are safe until Phase 2 extends RLS.
# ---------------------------------------------------------------------------
api_router.include_router(admin.router)
api_router.include_router(admin_dashboard.router)
api_router.include_router(admin_audit.router)
@@ -53,42 +91,54 @@ api_router.include_router(admin_plan_limits.router)
api_router.include_router(admin_feature_flags.router)
api_router.include_router(admin_settings.router)
api_router.include_router(admin_categories.router)
api_router.include_router(accounts.router)
api_router.include_router(webhooks.router)
api_router.include_router(shares.router)
api_router.include_router(shared.router) # Public endpoints (no auth)
api_router.include_router(tree_markdown.router)
api_router.include_router(ratings.router)
api_router.include_router(analytics.router)
api_router.include_router(target_lists.router)
api_router.include_router(maintenance_schedules.router)
api_router.include_router(feedback.router)
api_router.include_router(ai_builder.router)
api_router.include_router(ai_fix.router)
api_router.include_router(ai_chat.router)
api_router.include_router(copilot.router)
api_router.include_router(assistant_chat.router)
api_router.include_router(survey.router)
api_router.include_router(admin_survey.router)
api_router.include_router(tree_transfer.router)
api_router.include_router(ai_suggestions.router)
api_router.include_router(kb_accelerator.router)
api_router.include_router(beta_signup.router)
api_router.include_router(scripts.router)
api_router.include_router(integrations.router)
api_router.include_router(onboarding.router)
api_router.include_router(branding.router)
api_router.include_router(supporting_data.router)
api_router.include_router(session_handoffs.queue_router) # Must be before ai_sessions to avoid /{session_id} conflict
api_router.include_router(session_resolutions.router) # Must be before ai_sessions to avoid /{session_id} conflict
api_router.include_router(ai_sessions.router)
api_router.include_router(flow_proposals.router)
api_router.include_router(flowpilot_analytics.router)
api_router.include_router(notifications.router)
api_router.include_router(public_templates.router)
api_router.include_router(admin_gallery.router)
api_router.include_router(uploads.router)
api_router.include_router(script_builder.router)
api_router.include_router(beta_feedback.router)
api_router.include_router(session_branches.router)
api_router.include_router(session_handoffs.router)
# ---------------------------------------------------------------------------
# User-facing endpoints — tenant context required
# ---------------------------------------------------------------------------
_tenant_deps = [Depends(require_tenant_context)]
api_router.include_router(trees.router, dependencies=_tenant_deps)
api_router.include_router(sidebar.router, dependencies=_tenant_deps)
api_router.include_router(sessions.router, dependencies=_tenant_deps)
api_router.include_router(invite.router, dependencies=_tenant_deps)
api_router.include_router(categories.router, dependencies=_tenant_deps)
api_router.include_router(tags.router, dependencies=_tenant_deps)
api_router.include_router(folders.router, dependencies=_tenant_deps)
api_router.include_router(step_categories.router, dependencies=_tenant_deps)
api_router.include_router(steps.router, dependencies=_tenant_deps)
api_router.include_router(accounts.router, dependencies=_tenant_deps)
api_router.include_router(shares.router, dependencies=_tenant_deps)
api_router.include_router(tree_markdown.router, dependencies=_tenant_deps)
api_router.include_router(ratings.router, dependencies=_tenant_deps)
api_router.include_router(analytics.router, dependencies=_tenant_deps)
api_router.include_router(target_lists.router, dependencies=_tenant_deps)
api_router.include_router(maintenance_schedules.router, dependencies=_tenant_deps)
api_router.include_router(feedback.router, dependencies=_tenant_deps)
api_router.include_router(ai_builder.router, dependencies=_tenant_deps)
api_router.include_router(ai_fix.router, dependencies=_tenant_deps)
api_router.include_router(ai_chat.router, dependencies=_tenant_deps)
api_router.include_router(copilot.router, dependencies=_tenant_deps)
api_router.include_router(assistant_chat.router, dependencies=_tenant_deps)
api_router.include_router(survey.router, dependencies=_tenant_deps)
api_router.include_router(tree_transfer.router, dependencies=_tenant_deps)
api_router.include_router(ai_suggestions.router, dependencies=_tenant_deps)
api_router.include_router(kb_accelerator.router, dependencies=_tenant_deps)
api_router.include_router(scripts.router, dependencies=_tenant_deps)
api_router.include_router(integrations.router, dependencies=_tenant_deps)
api_router.include_router(onboarding.router, dependencies=_tenant_deps)
api_router.include_router(branding.router, dependencies=_tenant_deps)
api_router.include_router(supporting_data.router, dependencies=_tenant_deps)
# session_handoffs queue router must come before ai_sessions to avoid conflict
api_router.include_router(session_handoffs.queue_router, dependencies=_tenant_deps)
api_router.include_router(session_resolutions.router, dependencies=_tenant_deps)
api_router.include_router(ai_sessions.router, dependencies=_tenant_deps)
api_router.include_router(flow_proposals.router, dependencies=_tenant_deps)
api_router.include_router(flowpilot_analytics.router, dependencies=_tenant_deps)
api_router.include_router(notifications.router, dependencies=_tenant_deps)
api_router.include_router(uploads.router, dependencies=_tenant_deps)
api_router.include_router(script_builder.router, dependencies=_tenant_deps)
api_router.include_router(beta_feedback.router, dependencies=_tenant_deps)
api_router.include_router(session_branches.router, dependencies=_tenant_deps)
api_router.include_router(session_handoffs.router, dependencies=_tenant_deps)

View File

@@ -0,0 +1,36 @@
# backend/app/core/admin_database.py
"""
Admin database engine — connects as resolutionflow_admin (BYPASSRLS).
Use ONLY for /admin/* endpoints and internal tooling.
Never use this engine from user-facing endpoints.
"""
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.config import settings
admin_engine = create_async_engine(
settings.ADMIN_DATABASE_URL,
echo=settings.DEBUG,
future=True,
)
_admin_session_factory = async_sessionmaker(
admin_engine,
class_=AsyncSession,
expire_on_commit=False,
)
async def get_admin_db() -> AsyncGenerator[AsyncSession, None]:
"""Yield an admin DB session (BYPASSRLS). Use only on /admin/* endpoints."""
async with _admin_session_factory() as session:
try:
yield session
except Exception:
await session.rollback()
raise
finally:
await session.close()

View File

@@ -23,10 +23,33 @@ class Settings(BaseSettings):
return v.replace("postgresql://", "postgresql+asyncpg://", 1)
return v
@property
def DATABASE_URL_SYNC(self) -> str:
"""Get sync URL by removing asyncpg prefix from DATABASE_URL."""
return self.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://", 1)
# Sync URL for Alembic migrations. Defaults to DATABASE_URL (sync-converted).
# Set explicitly in .env to use a different role for migrations (e.g. superuser)
# when DATABASE_URL has been switched to the app role.
DATABASE_URL_SYNC: str = ""
@field_validator("DATABASE_URL_SYNC", mode="before")
@classmethod
def default_database_url_sync(cls, v: str, info) -> str:
"""Fall back to sync-converted DATABASE_URL if not explicitly set."""
if not v:
base = info.data.get("DATABASE_URL", "")
return base.replace("postgresql+asyncpg://", "postgresql://", 1)
return v
# Admin database — resolutionflow_admin role, BYPASSRLS.
# Used by /admin/* endpoints. Defaults to DATABASE_URL for local dev.
ADMIN_DATABASE_URL: str = ""
@field_validator("ADMIN_DATABASE_URL", mode="before")
@classmethod
def default_admin_database_url(cls, v: str, info) -> str:
"""Fall back to DATABASE_URL if ADMIN_DATABASE_URL is not set."""
if not v:
return info.data.get("DATABASE_URL", "")
if v.startswith("postgresql://"):
return v.replace("postgresql://", "postgresql+asyncpg://", 1)
return v
# JWT Settings
SECRET_KEY: str = _DEFAULT_SECRET_KEY

View File

@@ -1,6 +1,7 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from .config import settings
from app.core.tenant_context import register_tenant_listener
# Create async engine
engine = create_async_engine(
@@ -16,6 +17,11 @@ async_session_maker = async_sessionmaker(
expire_on_commit=False
)
# Register the RLS tenant context listener on the app engine.
# Fires at the start of every transaction; issues set_config automatically.
# Must NOT be called on admin_engine — admin connections bypass RLS.
register_tenant_listener(engine)
class Base(DeclarativeBase):
"""Base class for all database models."""

View File

@@ -1,10 +1,12 @@
"""
Centralized query filters for ResolutionFlow.
Provides reusable SQLAlchemy filter builders for tree access control
and step visibility, used across multiple endpoint modules.
Provides reusable SQLAlchemy filter builders for tree access control,
step visibility, and the canonical tenant_filter used by all queries
on tenant-scoped tables.
"""
from __future__ import annotations
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import or_, and_, true as sa_true
@@ -13,6 +15,18 @@ if TYPE_CHECKING:
from app.models.user import User
def tenant_filter(model, account_id: uuid.UUID):
"""Primary app-layer tenant filter.
MUST be used in every SELECT/UPDATE/DELETE on tenant tables.
RLS (Phase 2) is the safety net — this is the primary enforcement.
Usage:
stmt = select(Tree).where(tenant_filter(Tree, current_user.account_id), ...)
"""
return model.account_id == account_id
def build_tree_access_filter(current_user: User):
"""Build the access filter for trees based on user permissions.
@@ -36,10 +50,11 @@ def build_tree_access_filter(current_user: User):
Tree.author_id == current_user.id,
]
if current_user.account_id:
# Team-visible trees: use tenant_filter as the account match
conditions.append(
and_(
Tree.visibility == 'team',
Tree.account_id == current_user.account_id
tenant_filter(Tree, current_user.account_id),
)
)
return or_(*conditions)
@@ -58,11 +73,14 @@ def build_step_visibility_filter(current_user: User):
if current_user.account_id:
return or_(
StepLibrary.visibility == 'public',
and_(StepLibrary.visibility == 'team', StepLibrary.account_id == current_user.account_id),
StepLibrary.created_by == current_user.id # Own private steps
and_(
StepLibrary.visibility == 'team',
tenant_filter(StepLibrary, current_user.account_id),
),
StepLibrary.created_by == current_user.id,
)
else:
return or_(
StepLibrary.visibility == 'public',
StepLibrary.created_by == current_user.id
StepLibrary.created_by == current_user.id,
)

View File

@@ -18,6 +18,10 @@ logger = logging.getLogger(__name__)
SERVICE_ACCOUNT_EMAIL = "noreply@resolutionflow.com"
SERVICE_ACCOUNT_NAME = "ResolutionFlow"
# Well-known UUID for the platform account — owns all default/global content.
# Created by migration 3a40fe11b427_create_global_content_tables.
PLATFORM_ACCOUNT_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
SYSTEM_ACCOUNT_NAME = "ResolutionFlow System"
SYSTEM_ACCOUNT_DISPLAY_CODE = "RF-SYS-1"

View File

@@ -0,0 +1,92 @@
# backend/app/core/tenant_context.py
"""
Per-request tenant context for row-level security.
Flow:
1. require_tenant_context (FastAPI dep) calls set_current_account_id().
2. The SQLAlchemy transaction-begin listener fires on every new transaction
and calls set_config('app.current_account_id', <id>, true) automatically.
3. PostgreSQL RLS policies read current_setting('app.current_account_id', TRUE)
to filter rows.
The ContextVar is asyncio-task-scoped: each concurrent request has its own value.
set_config with is_local=true is transaction-scoped: it resets on COMMIT or
ROLLBACK, so the listener re-applies it at the start of every transaction.
"""
import contextvars
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import event, or_, text
from sqlalchemy.ext.asyncio import AsyncEngine
if TYPE_CHECKING:
from app.models.user import User
# One slot per async task — each concurrent request gets its own value.
_current_account_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"current_account_id", default=None
)
# Platform account — global content visible to all tenants.
PLATFORM_ACCOUNT_ID = UUID("00000000-0000-0000-0000-000000000001")
def set_current_account_id(account_id: UUID) -> contextvars.Token:
"""Set tenant context for the current request coroutine.
Returns a token so the caller can reset it after the request.
"""
return _current_account_id.set(str(account_id))
def clear_current_account_id(token: contextvars.Token) -> None:
"""Reset the ContextVar to its previous value (call in finally block)."""
_current_account_id.reset(token)
def get_current_account_id() -> str | None:
"""Return the account_id string for the current request, or None."""
return _current_account_id.get()
def register_tenant_listener(engine: AsyncEngine) -> None:
"""Register the transaction-begin listener on the given engine.
Must be called once at application startup, AFTER the engine is created.
The listener issues set_config() at the start of every transaction so that
the setting is re-applied automatically even when a request commits
mid-flight and starts a new transaction.
Do NOT call this on admin_engine — admin connections must never set tenant
context automatically.
"""
@event.listens_for(engine.sync_engine, "begin")
def _on_transaction_begin(conn) -> None: # noqa: ANN001
account_id = _current_account_id.get()
if account_id:
# set_config(name, value, is_local=true) ≡ SET LOCAL.
# Unlike SET LOCAL, set_config IS parameterisable.
conn.execute(
text("SELECT set_config('app.current_account_id', :id, true)"),
{"id": account_id},
)
# If no account_id is set, do nothing. The RLS policy falls back to a
# null-matching UUID and returns zero rows — fail-closed behaviour.
def tenant_filter(Model, current_user: "User"): # noqa: ANN001
"""SQLAlchemy filter clause for tables that contain platform-owned rows.
Use for: tree_tags, tree_categories, step_categories, step_library,
template_trees, platform_steps.
For tenant-only tables (trees, sessions, psa_connections, etc.) use:
Model.account_id == current_user.account_id
directly.
"""
return or_(
Model.account_id == current_user.account_id,
Model.account_id == PLATFORM_ACCOUNT_ID,
)

View File

@@ -54,6 +54,8 @@ from .session_branch import SessionBranch
from .fork_point import ForkPoint
from .session_handoff import SessionHandoff
from .session_resolution_output import SessionResolutionOutput
from .template_tree import TemplateTree
from .platform_step import PlatformStep
__all__ = [
"User",
@@ -122,4 +124,6 @@ __all__ = [
"ForkPoint",
"SessionHandoff",
"SessionResolutionOutput",
"TemplateTree",
"PlatformStep",
]

View File

@@ -50,6 +50,13 @@ class AISessionStep(Base):
nullable=False,
index=True,
)
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.",
)
step_order: Mapped[int] = mapped_column(
Integer, nullable=False,
comment="Sequential position in the session (0-indexed)",

View File

@@ -28,6 +28,12 @@ class AISuggestion(Base):
nullable=False,
index=True,
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("ai_chat_sessions.id", ondelete="SET NULL"),

View File

@@ -20,6 +20,12 @@ class Attachment(Base):
ForeignKey("sessions.id"),
nullable=False
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
node_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
file_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)

View File

@@ -39,10 +39,10 @@ class TreeCategory(Base):
nullable=True,
index=True
)
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=True,
nullable=False,
index=True
)
display_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)

View File

@@ -1,6 +1,5 @@
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import String, Text, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
@@ -11,7 +10,7 @@ class Feedback(Base):
__tablename__ = "feedback"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True)
account_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=False)
email: Mapped[str] = mapped_column(String(255), nullable=False)
feedback_type: Mapped[str] = mapped_column(String(50), nullable=False)

View File

@@ -46,6 +46,12 @@ class UserFolder(Base):
nullable=False,
index=True
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
color: Mapped[str] = mapped_column(String(7), nullable=False, default="#6366f1")
icon: Mapped[str] = mapped_column(String(50), nullable=False, default="folder")

View File

@@ -23,6 +23,12 @@ class ForkPoint(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
parent_branch_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("session_branches.id", ondelete="CASCADE"), nullable=False)
trigger_step_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_session_steps.id", ondelete="SET NULL"), nullable=True)
fork_reason: Mapped[str] = mapped_column(Text, nullable=False)

View File

@@ -23,6 +23,12 @@ class MaintenanceSchedule(Base):
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
cron_expression: Mapped[str] = mapped_column(String(100), nullable=False)
timezone: Mapped[str] = mapped_column(String(100), nullable=False, default="UTC")
target_list_id: Mapped[Optional[uuid.UUID]] = mapped_column(

View File

@@ -31,6 +31,12 @@ class NotificationLog(Base):
nullable=False,
index=True,
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
event: Mapped[str] = mapped_column(String(50), nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
status: Mapped[str] = mapped_column(String(20), default="sent")

View File

@@ -0,0 +1,37 @@
"""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),
)

View File

@@ -25,6 +25,12 @@ class PsaMemberMapping(Base):
nullable=False,
index=True,
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),

View File

@@ -35,6 +35,12 @@ class PsaPostLog(Base):
ForeignKey("psa_connections.id", ondelete="SET NULL"),
nullable=True,
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
ticket_id: Mapped[str] = mapped_column(String(100), nullable=False)
note_type: Mapped[str] = mapped_column(String(50), nullable=False)
content_posted: Mapped[str] = mapped_column(Text, nullable=False)

View File

@@ -29,6 +29,12 @@ class ScriptBuilderSession(Base):
nullable=False,
index=True,
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("teams.id", ondelete="SET NULL"),

View File

@@ -44,6 +44,12 @@ class ScriptTemplate(Base):
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("teams.id", ondelete="CASCADE"), nullable=True, index=True
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
@@ -97,6 +103,12 @@ class ScriptGeneration(Base):
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("teams.id", ondelete="SET NULL"), nullable=True, index=True
)

View File

@@ -31,6 +31,12 @@ class Session(Base):
nullable=False,
index=True
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
tree_snapshot: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
path_taken: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
decisions: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, default=list)

View File

@@ -35,6 +35,12 @@ class SessionBranch(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
parent_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("session_branches.id", ondelete="CASCADE"), nullable=True)
fork_point_step_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_session_steps.id", ondelete="SET NULL"), nullable=True)
branch_order: Mapped[int] = mapped_column(Integer, nullable=False, default=1)

View File

@@ -27,6 +27,12 @@ class SessionHandoff(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
handed_off_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
intent: Mapped[str] = mapped_column(String(20), nullable=False)
source_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("session_branches.id", ondelete="SET NULL"), nullable=True)

View File

@@ -23,6 +23,12 @@ class SessionResolutionOutput(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
output_type: Mapped[str] = mapped_column(String(30), nullable=False)
generated_content: Mapped[str] = mapped_column(Text, nullable=False)
structured_data: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True, comment="For KB: {symptoms, root_cause, steps, tags}")

View File

@@ -38,10 +38,10 @@ class StepCategory(Base):
nullable=True,
index=True
)
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=True,
nullable=False,
index=True
)
display_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)

View File

@@ -46,10 +46,10 @@ class StepLibrary(Base):
ForeignKey("teams.id", ondelete="CASCADE"),
nullable=True
)
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=True,
nullable=False,
index=True
)
@@ -143,6 +143,13 @@ class StepRating(Base):
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False
)
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).",
)
rating: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
was_helpful: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
review_text: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
@@ -187,6 +194,13 @@ class StepUsageLog(Base):
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False
)
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.",
)
session_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sessions.id", ondelete="CASCADE"),

View File

@@ -14,6 +14,12 @@ class SessionSupportingData(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
label: Mapped[str] = mapped_column(String(255), nullable=False)
data_type: Mapped[str] = mapped_column(Enum("text_snippet", "screenshot", name="supporting_data_type"), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)

View File

@@ -51,10 +51,10 @@ class TreeTag(Base):
nullable=True,
index=True
)
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=True,
nullable=False,
index=True
)
usage_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)

View File

@@ -9,6 +9,7 @@ from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.team import Team
from app.models.account import Account
class TargetList(Base):
@@ -21,6 +22,12 @@ class TargetList(Base):
UUID(as_uuid=True), ForeignKey("teams.id", ondelete="CASCADE"),
nullable=False, index=True
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)

View File

@@ -0,0 +1,40 @@
"""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),
)

View File

@@ -76,10 +76,10 @@ class Tree(Base):
nullable=True,
index=True
)
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=True,
nullable=False,
index=True
)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)

View File

@@ -37,10 +37,10 @@ class TreeEmbedding(Base):
ForeignKey("trees.id", ondelete="CASCADE"),
nullable=False,
)
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=True,
nullable=False,
)
chunk_type: Mapped[str] = mapped_column(
String(30),

View File

@@ -43,10 +43,10 @@ class User(Base):
must_change_password: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
# Account-based multi-tenancy (new)
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="RESTRICT"),
nullable=True,
nullable=False,
index=True
)
account_role: Mapped[str] = mapped_column(String(50), nullable=False, default="engineer")

View File

@@ -24,6 +24,12 @@ class UserPinnedTree(Base):
nullable=False,
index=True
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
tree_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("trees.id", ondelete="CASCADE"),

View File

@@ -127,6 +127,7 @@ class SessionDocumentation(BaseModel):
diagnostic_steps: list[DocumentationStep]
resolution_summary: str | None = None
escalation_reason: str | None = None
follow_up_recommendations: list[str] = []
total_steps: int
duration_display: str | None = None
generated_at: datetime
@@ -146,7 +147,7 @@ class StatusUpdateRequest(BaseModel):
"""Generate a mid-session or post-session status update."""
audience: str = Field(
...,
pattern="^(ticket_notes|client_update|email_draft)$",
pattern="^(ticket_notes|client_update|email_draft|request_info)$",
description="Who is this update for?",
)
length: str = Field(

View File

@@ -77,6 +77,9 @@ scope narrows it to this endpoint.
- JSON array of objects with `text` (required) and `context` (optional, 1 sentence)
- 1-3 questions per response
- Do NOT ask questions inline in your prose. ALL questions go in the marker.
- If the engineer's message contains tasks marked `_(not yet completed)_`, re-include \
those as questions/actions in your next response UNLESS you are ≥75% confident the \
information is no longer needed to resolve the issue. Default to keeping them.
**[ACTIONS] marker format:**
- JSON array of objects with `label` (required), `command` (optional), `description` (required)
@@ -155,6 +158,8 @@ To create a fork, append this marker AFTER your [QUESTIONS]/[ACTIONS] markers:
Every single response MUST contain [QUESTIONS] and/or [ACTIONS] markers with valid JSON. \
No exceptions. Not even when forking. A response without at least one of these markers \
will crash the UI. If you are unsure, include both. The markers are REQUIRED output, not optional.
If any tasks in the engineer's message are marked `_(not yet completed)_`, re-include them \
in your markers unless you are ≥75% confident that information is no longer relevant.
"""
@@ -298,6 +303,8 @@ async def _call_anthropic_cached(
}
]
_mcp_active = mcp_servers is not anthropic.NOT_GIVEN
try:
response = await client.beta.messages.create(
model=settings.AI_MODEL_ANTHROPIC,
@@ -308,12 +315,22 @@ async def _call_anthropic_cached(
tools=tools,
betas=["mcp-client-2025-11-20"],
)
except anthropic.BadRequestError as e:
# MCP server failures (rate limits, connection errors) should not
# block the assistant entirely — retry without MCP tools.
if "MCP server" in str(e) and mcp_servers is not anthropic.NOT_GIVEN:
logger.warning("MCP server error, retrying without MCP: %s", e)
response = await client.beta.messages.create(
except Exception as e:
# MCP server failures surface as many error types — BadRequestError,
# APIStatusError, APIConnectionError, APITimeoutError. Always retry
# without MCP when MCP was active, so a flaky external server never
# blocks the assistant entirely.
_is_mcp_error = _mcp_active and (
"MCP server" in str(e)
or "mcp" in type(e).__name__.lower()
or isinstance(e, (anthropic.BadRequestError, anthropic.APIStatusError))
)
if _is_mcp_error:
logger.warning(
"MCP server error (%s), retrying without MCP: %s",
type(e).__name__, e,
)
response = await client.messages.create(
model=settings.AI_MODEL_ANTHROPIC,
max_tokens=max_tokens,
system=system_blocks,

View File

@@ -8,7 +8,7 @@ from datetime import datetime, timezone, timedelta
from typing import Optional, Any
from uuid import UUID
from sqlalchemy import select
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -103,13 +103,23 @@ async def start_conversation(
Returns (conversation, greeting_message).
"""
# Load tree
# Load tree — must be accessible to this account.
# Allows own account's trees, default trees, and public trees.
# Raises ValueError (caught by endpoint as 404) if not found or not accessible.
result = await db.execute(
select(Tree).options(selectinload(Tree.tags)).where(Tree.id == tree_id)
select(Tree).options(selectinload(Tree.tags)).where(
Tree.id == tree_id,
or_(
Tree.account_id == account_id,
Tree.author_id == user_id,
Tree.is_default == True,
Tree.is_public == True,
),
)
)
tree = result.scalar_one_or_none()
if not tree:
raise ValueError(f"Tree {tree_id} not found")
raise ValueError(f"Tree {tree_id} not found or not accessible")
conversation = CopilotConversation(
user_id=user_id,

View File

@@ -911,16 +911,36 @@ async def generate_status_update(
steps_summary = []
for step in sorted(session.steps, key=lambda s: s.step_order):
content = step.content or {}
text = content.get("text", "")
response = step.free_text_input or step.selected_option or ("Skipped" if step.was_skipped else None)
if content.get("type") in ("resolution_suggestion", "briefing", "status_update"):
continue
text = content.get("text", "").strip()
if not text:
continue
# Resolve option label instead of raw machine value
response = None
if step.was_skipped:
response = "Skipped"
elif step.selected_option and step.options_presented:
for opt in step.options_presented:
if opt.get("value") == step.selected_option:
response = opt.get("label", step.selected_option)
break
else:
response = step.selected_option
elif step.selected_option:
response = step.selected_option
elif step.free_text_input:
response = step.free_text_input
outcome = None
if step.action_result:
outcome = "Succeeded" if step.action_result.get("success") else "Did not resolve"
entry = f"Step {step.step_order + 1}: {text}"
if response:
entry += f"\n Engineer response: {response}"
entry = f"{step.step_order + 1}. {text}"
if response and response != "Skipped":
entry += f" {response}"
elif response == "Skipped":
entry += " (skipped)"
if outcome:
entry += f"\n Outcome: {outcome}"
entry += f" [{outcome}]"
steps_summary.append(entry)
steps_text = "\n".join(steps_summary) if steps_summary else "No diagnostic steps yet."
@@ -929,13 +949,8 @@ async def generate_status_update(
now = datetime.now(timezone.utc)
ref_time = session.resolved_at or now
delta = ref_time - session.created_at
total_minutes = int(delta.total_seconds() / 60)
if total_minutes < 60:
time_display = f"{total_minutes} minutes"
else:
hours = total_minutes // 60
remaining = total_minutes % 60
time_display = f"{hours}h {remaining}m"
total_hrs = round(delta.total_seconds() / 3600, 2)
time_display = f"{total_hrs} hrs"
# Extract client name from intake or ticket data
client_name = None
@@ -1135,8 +1150,9 @@ def _build_status_update_prompt(
Rules:
- Be technical, concise, and factual
- Use markdown formatting (bold headers, bullet lists)
- Include: current status, steps completed, findings, what's been ruled out, next steps
- Use plain text with simple section headers (no markdown bold/bullets — PSA renders raw text)
- Structure as: current status paragraph, then "What We Know" section, then next steps
- "What We Know" should list confirmed findings, ruled-out causes, and open questions — keep each item to one line
- Do NOT soften language or add pleasantries
- Do NOT include greetings or sign-offs
- {length_instruction}
@@ -1147,28 +1163,54 @@ Output ONLY the update text. No JSON, no markdown code fences, no preamble."""
elif audience == "client_update":
client_greeting = f"Address the client as '{client_name}'" if client_name else "Use a generic greeting like 'Hi'"
return f"""You are generating a client-facing {context_label}.
context_guidance = {
"status": "We're actively working on it. Describe progress made so far and what comes next without giving a timeline.",
"resolution": "This is good news — the issue is resolved. Summarize what was wrong and what was done in plain language.",
"escalation": "Be reassuring — explain that a specialist is being brought in to assist, not that something failed.",
}.get(context, "")
return f"""You are generating a brief client-facing {context_label}.
Rules:
- Be professional, reassuring, and non-technical
- NEVER use technical jargon (no "transport rules", "MX records", "DNS", "registry", "GPO", etc.)
- NEVER include server names, IP addresses, internal tool names, or technical identifiers
- NEVER use technical jargon (no "transport rules", "MX records", "DNS", "registry", "GPO", "connector", etc.)
- NEVER include server names, IP addresses, internal tool names, or ticket IDs
- Explain findings in plain language a non-technical business owner would understand
- {client_greeting}
- Sign off with: {engineer_name}
- {length_instruction}
{"- This is good news — the issue is resolved. Summarize what was wrong and what was done in plain language." if context == "resolution" else ""}
{"- Be reassuring — explain that a specialist is being brought in, not that something failed." if context == "escalation" else ""}
- {context_guidance}
Output ONLY the update text. No JSON, no markdown code fences, no preamble."""
elif audience == "request_info":
client_greeting = f"Address the client as '{client_name}'" if client_name else "Use a generic greeting like 'Hi'"
return f"""You are generating a brief, professional message requesting information from the client.
Rules:
- Be friendly, concise, and non-technical
- Start with one sentence explaining what you're currently working on (plain language, no jargon)
- Then list the specific questions you need answered, as a numbered list
- Each question should be clear and answerable by a non-technical user
- NEVER use technical jargon, server names, IP addresses, or internal tool names
- {client_greeting}
- Sign off with: {engineer_name}
- Keep it short — this is a targeted ask, not a status update
Output ONLY the message text. No JSON, no markdown code fences, no preamble."""
else: # email_draft
client_greeting = f"Address the client as '{client_name}'" if client_name else "Use a generic greeting like 'Hi'"
subject_hints = {
"status": "Update: [brief issue description]",
"resolution": "Resolved: [brief issue description]",
"escalation": "Update: [brief issue description] — Specialist Review",
"escalation": "Update: [brief issue description] — Specialist Assistance",
"need_info": "Quick Question: [brief issue description]",
}
context_guidance = {
"status": "We're actively working on it. Describe progress and next steps without giving a timeline.",
"resolution": "This is good news — the issue is resolved. Summarize what was wrong and what was done in plain language.",
"escalation": "Be reassuring — explain that a specialist is being brought in to assist, not that something failed.",
}.get(context, "")
return f"""You are generating a complete email draft for client communication.
Rules:
@@ -1177,15 +1219,63 @@ Rules:
- {client_greeting}
- Be professional, reassuring, and non-technical
- NEVER use technical jargon, server names, IP addresses, or internal tool names
- Include a professional sign-off with:
{engineer_name}
- Include a professional sign-off with: {engineer_name}
- {length_instruction}
{"- This is good news — the issue is resolved." if context == "resolution" else ""}
{"- Be reassuring — explain that a specialist is being brought in." if context == "escalation" else ""}
- {context_guidance}
Output ONLY the email text (Subject + body). No JSON, no markdown code fences, no preamble."""
def _build_what_we_know(session: AISession) -> str:
"""Build a 'What We Know' summary from evidence_items (cockpit) or derived from steps.
When the cockpit branch merges, session.evidence_items will be populated by the AI
with confirmed/ruled_out/pending classifications. Until then, we derive findings
from completed diagnostic steps.
"""
evidence_items = getattr(session, 'evidence_items', None)
if evidence_items:
confirmed = [e['text'] for e in evidence_items if e.get('status') == 'confirmed']
ruled_out = [e['text'] for e in evidence_items if e.get('status') == 'ruled_out']
pending = [e['text'] for e in evidence_items if e.get('status') == 'pending']
parts = []
if confirmed:
parts.append("Confirmed:\n" + "\n".join(f" - {t}" for t in confirmed))
if ruled_out:
parts.append("Ruled out:\n" + "\n".join(f" - {t}" for t in ruled_out))
if pending:
parts.append("Still investigating:\n" + "\n".join(f" - {t}" for t in pending))
return "\n".join(parts)
# Derive from completed steps
findings = []
for step in sorted(session.steps or [], key=lambda s: s.step_order):
content = step.content or {}
if content.get("type") in ("resolution_suggestion", "briefing", "status_update"):
continue
description = content.get("text", "").strip()
if not description or step.was_skipped:
continue
response = None
if step.selected_option and step.options_presented:
for opt in step.options_presented:
if opt.get("value") == step.selected_option:
response = opt.get("label", step.selected_option)
break
else:
response = step.selected_option
elif step.selected_option:
response = step.selected_option
elif step.free_text_input:
response = step.free_text_input
if response:
findings.append(f"{description}{response}")
if not findings:
return ""
return "Findings so far:\n" + "\n".join(f" - {f}" for f in findings)
def _build_status_update_context(
session: AISession,
steps_text: str,
@@ -1206,24 +1296,17 @@ def _build_status_update_context(
if session.psa_ticket_id:
parts.append(f"Ticket ID: {session.psa_ticket_id}")
parts.append(f"\nDiagnostic steps:\n{steps_text}")
what_we_know = _build_what_we_know(session)
if what_we_know:
parts.append(f"\nWhat we know:\n{what_we_know}")
parts.append(f"\nDiagnostic steps taken:\n{steps_text}")
if context == "resolution" and session.resolution_summary:
parts.append(f"\nResolution: {session.resolution_summary}")
if context == "escalation" and session.escalation_reason:
parts.append(f"\nEscalation reason: {session.escalation_reason}")
# Include recent conversation messages for richer context
messages = session.conversation_messages or []
if messages:
recent = messages[-10:] # Last 10 messages
convo_text = "\n".join(
f"{'Engineer' if m['role'] == 'user' else 'FlowPilot'}: {m['content'][:300]}"
for m in recent
if isinstance(m, dict) and "role" in m and "content" in m
)
parts.append(f"\nRecent conversation:\n{convo_text}")
return "\n".join(parts)
@@ -1420,6 +1503,7 @@ def _create_step_from_parsed(
def _generate_documentation(session: AISession) -> SessionDocumentation:
"""Generate structured documentation from a session's steps."""
diagnostic_steps = []
follow_up_recommendations: list[str] = []
for step in session.steps:
content = step.content or {}
@@ -1459,6 +1543,12 @@ def _generate_documentation(session: AISession) -> SessionDocumentation:
outcome=outcome,
))
# Collect follow-up recommendations from resolution suggestion steps
if content.get("type") == "resolution_suggestion":
recs = content.get("follow_up_recommendations", [])
if isinstance(recs, list):
follow_up_recommendations.extend(recs)
# Calculate duration
duration_display = None
if session.resolved_at and session.created_at:
@@ -1484,6 +1574,7 @@ def _generate_documentation(session: AISession) -> SessionDocumentation:
diagnostic_steps=diagnostic_steps,
resolution_summary=session.resolution_summary,
escalation_reason=session.escalation_reason,
follow_up_recommendations=follow_up_recommendations,
total_steps=session.step_count,
duration_display=duration_display,
generated_at=datetime.now(timezone.utc),

View File

@@ -57,181 +57,199 @@ def _format_datetime(dt: datetime | None) -> str:
return dt.strftime("%Y-%m-%d %I:%M %p UTC")
def _get_engineer_response(step) -> str | None:
"""Extract the engineer's response label from a step."""
if step.was_skipped:
return "Skipped"
if step.selected_option and step.options_presented:
for opt in step.options_presented:
if opt.get("value") == step.selected_option:
return opt.get("label", step.selected_option)
return step.selected_option
if step.selected_option:
return step.selected_option
if step.free_text_input:
return step.free_text_input
return None
def format_resolution_note(session: AISession, include_steps: bool = True) -> str:
"""Format a resolved session as a plain-text note for CW."""
lines = [
"═══ FlowPilot Session Documentation ═══",
f"Session: {session.id}",
]
# Engineer name from relationship if loaded, otherwise user_id
engineer_name = getattr(session, 'user', None)
if engineer_name and hasattr(engineer_name, 'name'):
lines.append(f"Engineer: {engineer_name.name}")
engineer_display = engineer_name.name if engineer_name and hasattr(engineer_name, 'name') else "Unknown"
lines.extend([
f"Date: {_format_datetime(session.resolved_at)}",
f"Started: {_format_datetime(session.created_at)}",
f"Ended: {_format_datetime(session.resolved_at)}",
])
# Duration
duration_str = ""
if session.resolved_at and session.created_at:
delta = session.resolved_at - session.created_at
minutes = int(delta.total_seconds() / 60)
if minutes < 60:
lines.append(f"Duration: {minutes}m")
else:
lines.append(f"Duration: {minutes // 60}h {minutes % 60}m")
total_hrs = round(delta.total_seconds() / 3600, 2)
duration_str = f"{total_hrs} hrs"
lines.append("")
lines.append("── Problem ──")
lines.append(session.problem_summary or "No summary available")
if session.problem_domain:
lines.append(f"Domain: {session.problem_domain}")
lines = [
f"FlowPilot Session — {engineer_display}{duration_str}",
f"Problem: {session.problem_summary or 'No summary available'}",
]
# Diagnostic steps
if include_steps and session.steps:
lines.append("")
lines.append("── Diagnosis Path ──")
lines.append("Steps:")
for step in session.steps:
content = step.content or {}
step_type = content.get("type", step.step_type).capitalize()
description = content.get("text", "")
response_text = ""
if step.was_skipped:
response_text = "Skipped"
elif step.selected_option:
# Try to find the label
if step.options_presented:
for opt in step.options_presented:
if opt.get("value") == step.selected_option:
response_text = opt.get("label", step.selected_option)
break
else:
response_text = step.selected_option
else:
response_text = step.selected_option
elif step.free_text_input:
response_text = step.free_text_input
lines.append(f"{step.step_order + 1}. [{step_type}] {description}")
if response_text:
lines.append(f" → Response: {response_text}")
if step.action_result:
result = step.action_result
outcome = "Succeeded" if result.get("success") else "Did not resolve"
if details := result.get("details"):
outcome += f"{details}"
lines.append(f" → Result: {outcome}")
step_type = content.get("type", "")
if step_type == "resolution_suggestion":
continue # Not a diagnostic step
description = content.get("text", "").strip()
if not description:
continue
response = _get_engineer_response(step)
line = f"{step.step_order + 1}. {description}"
if response and response != "Skipped":
line += f"{response}"
elif response == "Skipped":
line += " (skipped)"
lines.append(line)
# Resolution
lines.append("")
lines.append("── Resolution ──")
lines.append(session.resolution_summary or "No resolution summary")
lines.append(f"Resolution: {session.resolution_summary or 'No resolution summary'}")
if session.resolution_action:
lines.append(session.resolution_action)
# Confidence
lines.append("")
lines.append("── AI Confidence ──")
lines.append(f"Final confidence: {session.confidence_tier} ({session.confidence_score:.0%})")
# Follow-up recommendations from resolution suggestion step
follow_ups: list[str] = []
for step in session.steps:
content = step.content or {}
if content.get("type") == "resolution_suggestion":
recs = content.get("follow_up_recommendations", [])
if isinstance(recs, list):
follow_ups.extend(recs)
if follow_ups:
lines.append("")
lines.append("Follow-up:")
for rec in follow_ups:
lines.append(f"- {rec}")
# Timing section (always present)
# Timing
lines.append("")
lines.append("── Session Timing ──")
lines.append(f"Start: {_format_datetime(session.created_at)}")
lines.append(f"End: {_format_datetime(session.resolved_at)}")
if session.resolved_at and session.created_at:
delta = session.resolved_at - session.created_at
minutes = int(delta.total_seconds() / 60)
lines.append(f"Total: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Total: {minutes}m")
total_hrs = round(delta.total_seconds() / 3600, 2)
lines.append(f"Total: {total_hrs} hrs")
lines.append("")
lines.append("Generated by ResolutionFlow FlowPilot")
lines.append("Generated by ResolutionFlow")
return "\n".join(lines)
def _derive_what_we_know(session: AISession) -> tuple[list[str], list[str], list[str]]:
"""Return (confirmed, ruled_out, pending) findings.
Uses session.evidence_items when the cockpit branch is merged; falls back
to deriving from completed diagnostic steps.
"""
evidence_items = getattr(session, 'evidence_items', None)
if evidence_items:
confirmed = [e['text'] for e in evidence_items if e.get('status') == 'confirmed']
ruled_out = [e['text'] for e in evidence_items if e.get('status') == 'ruled_out']
pending = [e['text'] for e in evidence_items if e.get('status') == 'pending']
return confirmed, ruled_out, pending
# Derive from completed steps — all answered steps become findings
findings = []
for step in sorted(session.steps or [], key=lambda s: s.step_order):
content = step.content or {}
if content.get("type") in ("resolution_suggestion", "briefing", "status_update"):
continue
description = content.get("text", "").strip()
if not description or step.was_skipped:
continue
response = _get_engineer_response(step)
if response:
findings.append(f"{description}{response}")
return findings, [], []
def format_escalation_note(session: AISession, include_steps: bool = True) -> str:
"""Format an escalated session as a plain-text note for CW."""
engineer_obj = getattr(session, 'user', None)
engineer_display = engineer_obj.name if engineer_obj and hasattr(engineer_obj, 'name') else "Unknown"
escalated_to_obj = getattr(session, 'escalated_to', None)
escalated_to_display = escalated_to_obj.name if escalated_to_obj and hasattr(escalated_to_obj, 'name') else None
escalated_at = session.resolved_at or datetime.now(timezone.utc)
duration_str = ""
if session.created_at:
delta = escalated_at - session.created_at
total_hrs = round(delta.total_seconds() / 3600, 2)
duration_str = f"{total_hrs} hrs"
header = f"FlowPilot Escalation — {engineer_display}{duration_str}"
if escalated_to_display:
header += f"{escalated_to_display}"
lines = [
"═══ FlowPilot Escalation Documentation ═══",
f"Session: {session.id}",
header,
f"Problem: {session.problem_summary or 'No summary available'}",
]
engineer_name = getattr(session, 'user', None)
if engineer_name and hasattr(engineer_name, 'name'):
lines.append(f"Escalated by: {engineer_name.name}")
escalated_to = getattr(session, 'escalated_to', None)
if escalated_to and hasattr(escalated_to, 'name'):
lines.append(f"Escalated to: {escalated_to.name}")
else:
lines.append("Escalated to: Unassigned")
lines.extend([
f"Date: {_format_datetime(session.resolved_at or datetime.now(timezone.utc))}",
f"Started: {_format_datetime(session.created_at)}",
])
if session.resolved_at and session.created_at:
delta = session.resolved_at - session.created_at
minutes = int(delta.total_seconds() / 60)
lines.append(f"Duration: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Duration: {minutes}m")
lines.append("")
lines.append("── Problem ──")
lines.append(session.problem_summary or "No summary available")
# Work completed
# Work completed with responses
if include_steps and session.steps:
lines.append("")
lines.append("── Work Completed ──")
for step in session.steps:
lines.append("Work completed:")
for step in sorted(session.steps, key=lambda s: s.step_order):
content = step.content or {}
description = content.get("text", "")
lines.append(f"{step.step_order + 1}. {description}")
if content.get("type") in ("resolution_suggestion", "briefing", "status_update"):
continue
description = content.get("text", "").strip()
if not description:
continue
response = _get_engineer_response(step)
line = f"{step.step_order + 1}. {description}"
if response and response != "Skipped":
line += f"{response}"
elif response == "Skipped":
line += " (skipped)"
lines.append(line)
# What We Know
confirmed, ruled_out, pending = _derive_what_we_know(session)
if confirmed or ruled_out or pending:
lines.append("")
lines.append("What we know:")
for f in confirmed:
lines.append(f"{f}")
for f in ruled_out:
lines.append(f"{f}")
for f in pending:
lines.append(f" ? {f}")
# Escalation reason
lines.append("")
lines.append("── Escalation Reason ──")
lines.append(session.escalation_reason or "No reason provided")
lines.append(f"Escalation reason: {session.escalation_reason or 'No reason provided'}")
# Escalation package details
# Suggested next steps from escalation package
pkg = session.escalation_package or {}
if hypotheses := pkg.get("remaining_hypotheses"):
lines.append("")
lines.append("── Remaining Hypotheses ──")
if isinstance(hypotheses, list):
for h in hypotheses:
lines.append(f"- {h}")
else:
lines.append(str(hypotheses))
if suggestions := pkg.get("suggested_next_steps"):
lines.append("")
lines.append("── Suggested Next Steps ──")
if isinstance(suggestions, list):
for s in suggestions:
lines.append(f"- {s}")
else:
lines.append(str(suggestions))
lines.append("Suggested next steps:")
items = suggestions if isinstance(suggestions, list) else [str(suggestions)]
for s in items:
lines.append(f"- {s}")
# Timing
lines.append("")
lines.append("── Session Timing ──")
lines.append(f"Start: {_format_datetime(session.created_at)}")
escalated_at = session.resolved_at or datetime.now(timezone.utc)
lines.append(f"Escalated: {_format_datetime(escalated_at)}")
if session.created_at:
delta = escalated_at - session.created_at
minutes = int(delta.total_seconds() / 60)
lines.append(f"Total: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Total: {minutes}m")
total_hrs = round(delta.total_seconds() / 3600, 2)
lines.append(f"Total: {total_hrs} hrs")
lines.append("")
lines.append("Generated by ResolutionFlow FlowPilot")
lines.append("Generated by ResolutionFlow")
return "\n".join(lines)

View File

@@ -83,19 +83,55 @@ class ResolutionOutputGenerator:
return output
def _build_session_context(self, session: AISession) -> str:
intake = session.intake_content or {}
intake_text = intake.get("text", "") or str(intake)
parts = [
f"Problem: {session.problem_summary or 'Unknown'}",
f"Domain: {session.problem_domain or 'Unknown'}",
f"Original intake: {intake_text[:300]}",
f"Resolution: {session.resolution_summary or 'Not specified'}",
f"Steps taken: {session.step_count}",
]
msgs = session.conversation_messages or []
if msgs:
parts.append("\nConversation highlights:")
for msg in msgs[-10:]:
role = msg.get("role", "unknown")
content = msg.get("content", "")[:200]
parts.append(f" [{role}]: {content}")
steps = sorted(session.steps or [], key=lambda s: s.step_order)
diagnostic = []
follow_ups: list[str] = []
for step in steps:
content = step.content or {}
step_type = content.get("type", "")
if step_type == "resolution_suggestion":
recs = content.get("follow_up_recommendations", [])
if isinstance(recs, list):
follow_ups.extend(recs)
continue
description = content.get("text", "").strip()
if not description:
continue
response = None
if step.was_skipped:
response = "skipped"
elif step.selected_option and step.options_presented:
for opt in step.options_presented:
if opt.get("value") == step.selected_option:
response = opt.get("label", step.selected_option)
break
else:
response = step.selected_option
elif step.selected_option:
response = step.selected_option
elif step.free_text_input:
response = step.free_text_input
entry = f" {step.step_order + 1}. {description}"
if response and response != "skipped":
entry += f"{response}"
diagnostic.append(entry)
if diagnostic:
parts.append("\nDiagnostic steps:")
parts.extend(diagnostic)
if follow_ups:
parts.append("\nRecommended follow-up:")
parts.extend(f" - {r}" for r in follow_ups)
return "\n".join(parts)
def _psa_notes_prompt(self, context: str) -> str:

View File

@@ -237,8 +237,10 @@ async def send_chat_message(
ai_content, input_tokens, output_tokens = await _call_ai(**prompt_args)
# Update branch conversation
# Strip _(not yet completed)_ markers before storage (same reason as main path)
stored_message = message.replace("_(not yet completed)_", "(pending)").replace("_(skipped)_", "(skipped)")
msgs = list(branch.conversation_messages or [])
msgs.append({"role": "user", "content": message})
msgs.append({"role": "user", "content": stored_message})
msgs.append({"role": "assistant", "content": ai_content})
branch.conversation_messages = msgs
@@ -350,8 +352,14 @@ async def send_chat_message(
# Store DISPLAY content (markers stripped) in conversation_messages.
# The format reminder in the user message + system prompt final reminder
# are sufficient to keep the AI emitting markers on subsequent turns.
#
# Strip _(not yet completed)_ task markers from the stored user message.
# The AI processes them correctly on the current turn, but persisting them
# into history causes the AI to re-inject stale task lane items from prior
# turns — even across unrelated topics in a long session.
stored_message = message.replace("_(not yet completed)_", "(pending)").replace("_(skipped)_", "(skipped)")
msgs = list(session.conversation_messages or [])
msgs.append({"role": "user", "content": message})
msgs.append({"role": "user", "content": stored_message})
msgs.append({"role": "assistant", "content": display_content})
session.conversation_messages = msgs
session.step_count += 2 # message count for display

View File

@@ -0,0 +1,91 @@
"""
Tenant filter enforcement check.
Scans endpoint and service files for SQLAlchemy select() calls on known
tenant tables and warns when account_id or tenant_filter is not present
in the surrounding 15 lines (the typical extent of a single query).
Usage:
python scripts/check_tenant_filters.py # warn mode (exits 0)
python scripts/check_tenant_filters.py --fail # block mode (exits 1 on findings)
"""
import re
import sys
from pathlib import Path
# Tables that must always be filtered by account_id or tenant_filter.
# Extend this list as new tenant tables are added.
TENANT_MODELS = [
"Tree", "AISession", "Session", "StepLibrary", "FlowProposal",
"CopilotConversation", "AssistantChat", "FileUpload", "KBImport",
"PsaConnection", "PsaPostLog", "PsaMemberMapping", "AIChatSession",
"AIConversation", "AIUsage", "Subscription", "AccountInvite",
"Notification", "NotificationConfig", "SessionShare", "UserFolder",
"UserPinnedTree", "SessionBranch", "SessionHandoff",
"SessionResolutionOutput", "ForkPoint", "AISessionStep",
"AISuggestion", "StepCategory", "TreeCategory", "TreeTag",
"Attachment", "SessionSupportingData", "MaintenanceSchedule",
"AuditLog", "ScriptBuilderSession", "ScriptTemplate",
"StepRating", "StepUsageLog", "TargetList",
]
# Directories to scan
SCAN_DIRS = [
Path("app/api/endpoints"),
Path("app/services"),
]
# Patterns that indicate the query is correctly scoped.
# NOTE: user_id scoping is accepted for user-owned resources (sessions, folders, notifications).
# For account-shared resources (trees, steps, etc.) use tenant_filter or account_id.
SAFE_PATTERNS = [
r"tenant_filter",
r"account_id",
r"user_id", # User-scoped resources (sessions, folders, notifications, etc.)
r"is_super_admin", # Super admin queries intentionally bypass tenant filter
r"# cross-tenant: approved", # Explicit approval comment
]
SKIP_FILES = {
"admin.py", # Super admin endpoints intentionally bypass tenant filter
"admin_gallery.py", # Gallery management — super admin only, no tenant scoping needed
"public_templates.py",# Public template browser — intentionally cross-tenant
"auth.py", # Auth/registration — no account context during login/register
"ratings.py", # Session ratings — user-scoped via session lookup chain
}
findings = []
for scan_dir in SCAN_DIRS:
if not scan_dir.exists():
continue
for path in sorted(scan_dir.glob("*.py")):
if path.name in SKIP_FILES:
continue
lines = path.read_text().splitlines()
for i, line in enumerate(lines):
for model in TENANT_MODELS:
if re.search(rf"\bselect\s*\(\s*{model}\b", line):
# Check surrounding 15 lines for a safe pattern
start = max(0, i - 2)
end = min(len(lines), i + 15)
context = "\n".join(lines[start:end])
if not any(re.search(p, context) for p in SAFE_PATTERNS):
findings.append(
f"{path}:{i + 1}: select({model}) — no tenant_filter or account_id found in context"
)
if findings:
print(f"\n⚠ Tenant filter check — {len(findings)} warning(s):\n")
for f in findings:
print(f" {f}")
print()
if "--fail" in sys.argv:
print("Run with --fail: exiting 1")
sys.exit(1)
else:
print("Run in warn mode — not blocking. Pass --fail to block.")
sys.exit(0)
else:
print("✓ Tenant filter check passed — no unscoped tenant table queries found.")
sys.exit(0)

View File

@@ -0,0 +1,545 @@
"""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 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
@pytest.mark.asyncio
async def test_session_resolution_output_account_id(test_db: AsyncSession):
"""session_resolution_outputs.account_id must match the parent ai_session's account_id.
NOTE: session_resolution_outputs.session_id FK points to ai_sessions (not sessions).
"""
account, user = await _make_account_and_user(test_db, "sro1")
ai_session = AISession(
user_id=user.id,
account_id=account.id,
problem_summary="test resolution output",
problem_domain="networking",
status="active",
)
test_db.add(ai_session)
await test_db.flush()
output = SessionResolutionOutput(
session_id=ai_session.id,
account_id=account.id,
output_type="psa_ticket_notes",
generated_content="Ticket notes content",
generated_by_model="gpt-4",
)
test_db.add(output)
await test_db.commit()
result = await test_db.execute(
select(SessionResolutionOutput).where(SessionResolutionOutput.id == output.id)
)
row = result.scalar_one()
assert row.account_id == account.id
# ── 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
# ── 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}"
)
@pytest.mark.asyncio
async def test_step_usage_log_account_id_is_logger_account(test_db: AsyncSession):
"""step_usage_log.account_id must be the LOGGER's account (user who used the step)."""
from app.models.step_library import StepLibrary, StepUsageLog
account, user = await _make_account_and_user(test_db, "sul1")
tree = await _make_tree(test_db, account, user)
session = await _make_session(test_db, account, user, tree)
step = StepLibrary(
title="A usage step",
step_type="action",
content={"text": "do something"},
created_by=user.id,
account_id=account.id,
visibility="team",
)
test_db.add(step)
await test_db.flush()
log = StepUsageLog(
step_id=step.id,
user_id=user.id,
account_id=account.id,
session_id=session.id,
)
test_db.add(log)
await test_db.commit()
result = await test_db.execute(select(StepUsageLog).where(StepUsageLog.id == log.id))
row = result.scalar_one()
assert row.account_id == account.id, (
f"account_id should be logger's account ({account.id}), got {row.account_id}"
)
# ── 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
# ── 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
# ── 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
# ── 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
# ── 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
# ── 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)"
# ── 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."""
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,
role="engineer",
account_role="engineer",
# account_id intentionally omitted
))
await test_db.flush()

View File

@@ -0,0 +1,266 @@
# backend/tests/test_rls_isolation.py
"""
RLS foundation tests.
Connect directly as resolutionflow_app (not superuser) and verify:
- Tenant A cannot read Tenant B's rows
- No tenant context set → zero rows for private data (fail-closed)
- Platform rows (PLATFORM_ACCOUNT_ID) are visible to all tenants
Tests bypass FastAPI entirely — raw asyncpg connections only.
MUST FAIL before Task 10 (RLS migration) and PASS after it.
Run with:
DB_APP_ROLE_PASSWORD=app_secret_change_me pytest tests/test_rls_isolation.py -v
The test DB is patherly_test (matches conftest.py default).
"""
import os
import uuid
import asyncpg
import pytest
_DB_HOST = os.getenv("TEST_DB_HOST", "localhost")
_DB_PORT = int(os.getenv("TEST_DB_PORT", "5432"))
_DB_NAME = os.getenv("TEST_DB_NAME", "patherly_test") # matches conftest.py
_APP_PASSWORD = os.getenv("DB_APP_ROLE_PASSWORD", "app_secret_change_me")
_ADMIN_DSN = f"postgresql://postgres:postgres@{_DB_HOST}:{_DB_PORT}/{_DB_NAME}"
PLATFORM_ACCOUNT_ID = "00000000-0000-0000-0000-000000000001"
ACCOUNT_A_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
ACCOUNT_B_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
async def admin_conn():
"""Superuser asyncpg connection for fixture setup and teardown."""
conn = await asyncpg.connect(_ADMIN_DSN)
yield conn
await conn.close()
@pytest.fixture(scope="module", autouse=True)
async def seed_rls_test_data(admin_conn):
"""
Create two isolated test accounts, one user per account, and one private
tree per account. Trees require a valid author_id FK to users, so users
must be created first.
accounts.display_code must be unique and 8 chars (NOT NULL constraint).
"""
# Insert accounts
await admin_conn.execute(f"""
INSERT INTO accounts (id, name, display_code, created_at, updated_at)
VALUES
('{ACCOUNT_A_ID}', 'RLS Tenant A', 'RLSA0001', NOW(), NOW()),
('{ACCOUNT_B_ID}', 'RLS Tenant B', 'RLSB0001', NOW(), NOW())
ON CONFLICT (id) DO NOTHING
""")
# Insert one user per account (users.account_id NOT NULL, password_hash NOT NULL)
user_a_id = str(uuid.uuid4())
user_b_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO users (
id, email, password_hash, name, role, is_active, account_id,
account_role, created_at
) VALUES
('{user_a_id}', 'rls-user-a@example.com',
'placeholder', 'RLS User A', 'engineer', TRUE,
'{ACCOUNT_A_ID}', 'engineer', NOW()),
('{user_b_id}', 'rls-user-b@example.com',
'placeholder', 'RLS User B', 'engineer', TRUE,
'{ACCOUNT_B_ID}', 'engineer', NOW())
ON CONFLICT (email) DO NOTHING
""")
# Look up the user IDs we just inserted (ON CONFLICT may have skipped)
row_a = await admin_conn.fetchrow(
"SELECT id FROM users WHERE email = 'rls-user-a@example.com'"
)
row_b = await admin_conn.fetchrow(
"SELECT id FROM users WHERE email = 'rls-user-b@example.com'"
)
actual_user_a = str(row_a["id"])
actual_user_b = str(row_b["id"])
# Insert one private tree per account with explicit author_id
await admin_conn.execute(f"""
INSERT INTO trees (
id, name, tree_structure, account_id, author_id, is_active, is_default,
is_public, visibility, tree_type, created_at, updated_at
) VALUES
(gen_random_uuid(), 'RLS Tree A', '[]'::jsonb, '{ACCOUNT_A_ID}', '{actual_user_a}',
TRUE, FALSE, FALSE, 'private', 'troubleshooting', NOW(), NOW()),
(gen_random_uuid(), 'RLS Tree B', '[]'::jsonb, '{ACCOUNT_B_ID}', '{actual_user_b}',
TRUE, FALSE, FALSE, 'private', 'troubleshooting', NOW(), NOW())
""")
# One platform-owned tree_tag (global, visible to all tenants)
await admin_conn.execute(f"""
INSERT INTO tree_tags (
id, name, slug, account_id, usage_count, created_at
) VALUES (
gen_random_uuid(), 'rls-global-tag', 'rls-global-tag',
'{PLATFORM_ACCOUNT_ID}', 0, NOW()
) ON CONFLICT DO NOTHING
""")
yield
# Cleanup
await admin_conn.execute(
f"DELETE FROM trees WHERE account_id IN ('{ACCOUNT_A_ID}', '{ACCOUNT_B_ID}')"
)
await admin_conn.execute(
"DELETE FROM users WHERE email IN "
"('rls-user-a@example.com', 'rls-user-b@example.com')"
)
await admin_conn.execute(
f"DELETE FROM accounts WHERE id IN ('{ACCOUNT_A_ID}', '{ACCOUNT_B_ID}')"
)
await admin_conn.execute("DELETE FROM tree_tags WHERE slug = 'rls-global-tag'")
@pytest.fixture
async def conn_a():
"""App-role connection, tenant context = Account A."""
conn = await asyncpg.connect(
host=_DB_HOST, port=_DB_PORT, database=_DB_NAME,
user="resolutionflow_app", password=_APP_PASSWORD,
)
await conn.execute(
"SELECT set_config('app.current_account_id', $1, false)", ACCOUNT_A_ID
)
yield conn
await conn.close()
@pytest.fixture
async def conn_b():
"""App-role connection, tenant context = Account B."""
conn = await asyncpg.connect(
host=_DB_HOST, port=_DB_PORT, database=_DB_NAME,
user="resolutionflow_app", password=_APP_PASSWORD,
)
await conn.execute(
"SELECT set_config('app.current_account_id', $1, false)", ACCOUNT_B_ID
)
yield conn
await conn.close()
@pytest.fixture
async def conn_no_context():
"""App-role connection with NO tenant context set."""
conn = await asyncpg.connect(
host=_DB_HOST, port=_DB_PORT, database=_DB_NAME,
user="resolutionflow_app", password=_APP_PASSWORD,
)
yield conn
await conn.close()
# ---------------------------------------------------------------------------
# trees
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_trees_account_a_cannot_see_account_b_rows(conn_a):
rows = await conn_a.fetch(
f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B trees"
@pytest.mark.asyncio
async def test_trees_account_a_can_see_own_rows(conn_a):
rows = await conn_a.fetch(
f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_A_ID}'"
)
assert len(rows) >= 1, "Account A should see its own trees"
@pytest.mark.asyncio
async def test_trees_no_context_sees_no_private_trees(conn_no_context):
rows = await conn_no_context.fetch(
"SELECT id FROM trees WHERE is_default = FALSE AND is_public = FALSE"
)
assert len(rows) == 0, "No-context connection should see no private trees"
# ---------------------------------------------------------------------------
# tree_tags — platform visibility
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_tree_tags_account_a_cannot_see_account_b_tags(conn_a):
rows = await conn_a.fetch(
f"SELECT id FROM tree_tags WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0
@pytest.mark.asyncio
async def test_tree_tags_both_tenants_see_platform_tags(conn_a, conn_b):
rows_a = await conn_a.fetch(
f"SELECT id FROM tree_tags WHERE account_id = '{PLATFORM_ACCOUNT_ID}'"
)
rows_b = await conn_b.fetch(
f"SELECT id FROM tree_tags WHERE account_id = '{PLATFORM_ACCOUNT_ID}'"
)
assert len(rows_a) >= 1, "Account A should see platform tags"
assert len(rows_b) >= 1, "Account B should see platform tags"
# ---------------------------------------------------------------------------
# tree_categories — platform visibility
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_tree_categories_account_a_cannot_see_account_b(conn_a):
rows = await conn_a.fetch(
f"SELECT id FROM tree_categories WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0
# ---------------------------------------------------------------------------
# step_categories — platform visibility
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_step_categories_account_a_cannot_see_account_b(conn_a):
rows = await conn_a.fetch(
f"SELECT id FROM step_categories WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0
# ---------------------------------------------------------------------------
# psa_connections — tenant-only
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_psa_connections_account_a_cannot_see_account_b(conn_a):
rows = await conn_a.fetch(
f"SELECT id FROM psa_connections WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0
# ---------------------------------------------------------------------------
# flow_proposals — tenant-only
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_flow_proposals_account_a_cannot_see_account_b(conn_a):
rows = await conn_a.fetch(
f"SELECT id FROM flow_proposals WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0

View File

@@ -1,4 +1,6 @@
"""Integration tests for Script Template Editor permissions and share endpoint."""
from uuid import UUID as PyUUID
import pytest
from httpx import AsyncClient
from sqlalchemy import select
@@ -65,6 +67,9 @@ class TestScriptTemplatePermissions:
data = resp.json()
assert data["name"] == "Test Template"
assert data["created_by"] is not None
result = await test_db.execute(select(ScriptTemplate).where(ScriptTemplate.id == PyUUID(data["id"])))
template = result.scalar_one()
assert template.account_id is not None
@pytest.mark.asyncio
async def test_engineer_can_edit_own_template(self, client, auth_headers, test_db):

View File

@@ -6,14 +6,18 @@ from datetime import datetime, timezone
import pytest
import sqlalchemy as sa
from app.models.script_template import ScriptGeneration
from app.models.user import User
# ── Fixtures ──────────────────────────────────────────────────────────────
@pytest.fixture
async def seed_script_data(test_db):
async def seed_script_data(test_db, test_user):
"""Seed script categories and templates into the test database."""
now = datetime.now(timezone.utc)
cat_id = uuid.UUID("00000000-0000-0000-0000-000000000001")
user_result = await test_db.execute(sa.select(User).where(User.email == test_user["email"]))
user = user_result.scalar_one()
# Insert category
await test_db.execute(
@@ -142,20 +146,20 @@ async def seed_script_data(test_db):
await test_db.execute(
sa.text("""
INSERT INTO script_templates (
id, category_id, name, slug, description,
id, category_id, account_id, name, slug, description,
script_body, parameters_schema, default_values, validation_rules,
tags, complexity, estimated_runtime, requires_elevation,
requires_modules, version, is_verified, is_active, usage_count,
created_at, updated_at
) VALUES (
:id, :category_id, :name, :slug, :description,
:id, :category_id, :account_id, :name, :slug, :description,
:script_body, CAST(:parameters_schema AS jsonb), '{}'::jsonb, '{}'::jsonb,
CAST(:tags AS jsonb), :complexity, :estimated_runtime, :requires_elevation,
'[]'::jsonb, 1, true, true, 0,
:now, :now
)
"""),
{**tmpl, "category_id": cat_id, "now": now},
{**tmpl, "category_id": cat_id, "account_id": user.account_id, "now": now},
)
await test_db.commit()
@@ -245,7 +249,7 @@ async def test_get_template_detail_not_found(client, auth_headers):
# ── Generate ──────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_generate_script_success(client, auth_headers, seed_script_data):
async def test_generate_script_success(client, auth_headers, seed_script_data, test_db, test_user):
list_resp = await client.get(
"/api/v1/scripts/templates?search=unlock",
headers=auth_headers,
@@ -265,6 +269,13 @@ async def test_generate_script_success(client, auth_headers, seed_script_data):
assert "script" in data
assert "jsmith" in data["script"]
assert "id" in data
generation_result = await test_db.execute(
sa.select(ScriptGeneration).where(ScriptGeneration.id == uuid.UUID(data["id"]))
)
generation = generation_result.scalar_one()
user_result = await test_db.execute(sa.select(User).where(User.email == test_user["email"]))
user = user_result.scalar_one()
assert generation.account_id == user.account_id
@pytest.mark.asyncio

View File

@@ -0,0 +1,58 @@
import asyncio
from uuid import UUID
import pytest
from unittest.mock import MagicMock
from app.core.tenant_context import (
set_current_account_id,
clear_current_account_id,
get_current_account_id,
)
def test_contextvar_is_none_by_default():
assert get_current_account_id() is None
def test_set_and_clear():
account_id = UUID("aaaaaaaa-0000-0000-0000-000000000001")
token = set_current_account_id(account_id)
assert get_current_account_id() == str(account_id)
clear_current_account_id(token)
assert get_current_account_id() is None
def test_tasks_are_isolated():
"""Each asyncio task has its own ContextVar value."""
results = {}
async def set_in_task(name: str, value: str):
token = set_current_account_id(UUID(value))
await asyncio.sleep(0)
results[name] = get_current_account_id()
clear_current_account_id(token)
async def run():
await asyncio.gather(
set_in_task("a", "aaaaaaaa-0000-0000-0000-000000000001"),
set_in_task("b", "bbbbbbbb-0000-0000-0000-000000000002"),
)
asyncio.run(run())
assert results["a"] == "aaaaaaaa-0000-0000-0000-000000000001"
assert results["b"] == "bbbbbbbb-0000-0000-0000-000000000002"
@pytest.mark.asyncio
async def test_require_tenant_context_raises_403_when_no_account():
from fastapi import HTTPException
from app.api.deps import require_tenant_context
user = MagicMock()
user.account_id = None
gen = require_tenant_context(current_user=user)
with pytest.raises(HTTPException) as exc_info:
await gen.__anext__()
assert exc_info.value.status_code == 403
assert "account required" in exc_info.value.detail.lower()

View File

@@ -0,0 +1,578 @@
"""Phase 0 tenant-isolation tests.
Verifies that endpoints respect account boundaries and don't leak data
across tenants. Each task group tests a specific endpoint fix.
"""
import uuid
import datetime as dt
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.account import Account
from app.models.user import User
from app.models.tree import Tree
from app.core.security import get_password_hash
# ── Helpers ──────────────────────────────────────────────────────────────────
async def _create_account_and_user(db: AsyncSession, prefix: str):
"""Create a fresh account + engineer user. Returns (account, user, plain_password)."""
password = "TestPass123!"
account = Account(
name=f"{prefix}-corp",
display_code=uuid.uuid4().hex[:8],
)
db.add(account)
await db.flush()
user = User(
email=f"{prefix}-{uuid.uuid4().hex[:6]}@example.com",
name=f"{prefix} user",
password_hash=get_password_hash(password),
is_active=True,
account_id=account.id,
account_role="engineer",
)
db.add(user)
await db.flush()
return account, user, password
async def _login(client: AsyncClient, email: str, password: str) -> dict:
"""Log in and return Authorization headers."""
resp = await client.post(
"/api/v1/auth/login",
json={"email": email, "password": password},
)
assert resp.status_code == 200, f"Login failed: {resp.text}"
token = resp.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
async def _create_private_tree(db: AsyncSession, account: Account, user: User) -> Tree:
"""Create a private tree owned by the given account/user."""
tree = Tree(
name=f"Private Tree {uuid.uuid4().hex[:6]}",
account_id=account.id,
author_id=user.id,
visibility="private",
tree_type="troubleshooting",
tree_structure={"id": "root", "type": "start", "children": []},
is_active=True,
status="published",
)
db.add(tree)
await db.flush()
return tree
# ── Task 3: Analytics flow endpoint ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_analytics_flow_cannot_read_other_account_tree(
client: AsyncClient, test_db: AsyncSession
):
"""Account A cannot read flow analytics for Account B's private tree."""
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "anl-a")
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "anl-b")
tree_b = await _create_private_tree(test_db, acct_b, user_b)
await test_db.commit()
headers_a = await _login(client, user_a.email, pass_a)
resp = await client.get(
f"/api/v1/analytics/flows/{tree_b.id}",
headers=headers_a,
)
assert resp.status_code == 404, f"Expected 404, got {resp.status_code}: {resp.text}"
# ── Task 4: Category tree count ───────────────────────────────────────────────
@pytest.mark.asyncio
async def test_category_tree_count_scoped_to_account(
client: AsyncClient, test_db: AsyncSession
):
"""tree_count on a category must not include trees from other accounts."""
from app.models.category import TreeCategory
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "cat-a")
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "cat-b")
# Shared category (account_id=None means global)
category = TreeCategory(
name="Shared Category",
slug=f"shared-cat-{uuid.uuid4().hex[:6]}",
account_id=None,
is_active=True,
)
test_db.add(category)
await test_db.flush()
# 3 trees for account_b under this category
for i in range(3):
tree = Tree(
name=f"B Tree {i}",
account_id=acct_b.id,
author_id=user_b.id,
category_id=category.id,
visibility="team",
tree_type="troubleshooting",
tree_structure={"id": "root", "type": "start", "children": []},
is_active=True,
status="published",
)
test_db.add(tree)
# 1 tree for account_a under this category
tree_a = Tree(
name="A Tree",
account_id=acct_a.id,
author_id=user_a.id,
category_id=category.id,
visibility="team",
tree_type="troubleshooting",
tree_structure={"id": "root", "type": "start", "children": []},
is_active=True,
status="published",
)
test_db.add(tree_a)
await test_db.commit()
headers_a = await _login(client, user_a.email, pass_a)
resp = await client.get(
f"/api/v1/categories/{category.id}",
headers=headers_a,
)
assert resp.status_code == 200, resp.text
# account_a should only see their 1 tree, not account_b's 3
assert resp.json()["tree_count"] == 1, (
f"Expected tree_count=1 (own trees only), got {resp.json()['tree_count']}"
)
# ── Task 5: AI session search scope ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_ai_session_search_cannot_see_other_users_sessions(
client: AsyncClient, test_db: AsyncSession
):
"""User A cannot find User B's AI sessions via the search endpoint,
even when both users are in the same account."""
from app.models.ai_session import AISession
# Two users in the SAME account
account = Account(name="Shared Corp", display_code=uuid.uuid4().hex[:8])
test_db.add(account)
await test_db.flush()
password = "TestPass123!"
user_a = User(
email=f"user-a-{uuid.uuid4().hex[:6]}@shared.com",
name="User A",
password_hash=get_password_hash(password),
is_active=True,
account_id=account.id,
account_role="engineer",
)
user_b = User(
email=f"user-b-{uuid.uuid4().hex[:6]}@shared.com",
name="User B",
password_hash=get_password_hash(password),
is_active=True,
account_id=account.id,
account_role="engineer",
)
test_db.add_all([user_a, user_b])
await test_db.flush()
# Session belonging to user_b with distinctive problem_summary
session_b = AISession(
user_id=user_b.id,
account_id=account.id,
problem_summary="CONFIDENTIAL: user_b's session",
problem_domain="networking",
status="resolved",
)
test_db.add(session_b)
await test_db.commit()
headers_a = await _login(client, user_a.email, password)
resp = await client.get(
"/api/v1/ai-sessions/search",
params={"q": "CONFIDENTIAL"},
headers=headers_a,
)
assert resp.status_code == 200, resp.text
results = resp.json()
ids = [r["id"] for r in results]
assert str(session_b.id) not in ids, (
"User A can see User B's session via search — cross-user leak within account"
)
# ── Task 6: Cross-tenant UUID audit ─────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_tree_returns_404_not_403_for_other_account(
client: AsyncClient, test_db: AsyncSession
):
"""Account A gets 404 (not 403) when accessing Account B's private tree."""
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "t6-tree-a")
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "t6-tree-b")
tree_b = await _create_private_tree(test_db, acct_b, user_b)
await test_db.commit()
headers_a = await _login(client, user_a.email, pass_a)
resp = await client.get(f"/api/v1/trees/{tree_b.id}", headers=headers_a)
assert resp.status_code == 404, (
f"Expected 404 for cross-tenant tree access, got {resp.status_code}"
)
@pytest.mark.asyncio
async def test_update_tree_returns_404_not_403_for_other_account(
client: AsyncClient, test_db: AsyncSession
):
"""Account A gets 404 (not 403) when trying to update Account B's tree."""
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "t6-upd-a")
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "t6-upd-b")
tree_b = await _create_private_tree(test_db, acct_b, user_b)
await test_db.commit()
headers_a = await _login(client, user_a.email, pass_a)
resp = await client.put(
f"/api/v1/trees/{tree_b.id}",
json={"name": "Hacked"},
headers=headers_a,
)
assert resp.status_code == 404, (
f"Expected 404 for cross-tenant tree update, got {resp.status_code}"
)
@pytest.mark.asyncio
async def test_get_session_returns_404_not_403_for_other_user(
client: AsyncClient, test_db: AsyncSession
):
"""User A gets 404 (not 403) when accessing User B's session."""
from app.models.session import Session
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "t6-sess-a")
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "t6-sess-b")
tree_b = await _create_private_tree(test_db, acct_b, user_b)
session_b = Session(
tree_id=tree_b.id,
user_id=user_b.id,
tree_snapshot={"id": "root", "type": "start", "children": []},
path_taken=[],
decisions=[],
)
test_db.add(session_b)
await test_db.commit()
headers_a = await _login(client, user_a.email, pass_a)
resp = await client.get(f"/api/v1/sessions/{session_b.id}", headers=headers_a)
assert resp.status_code == 404, (
f"Expected 404 for cross-user session access, got {resp.status_code}"
)
@pytest.mark.asyncio
async def test_ai_session_get_returns_404_not_403_for_other_user(
client: AsyncClient, test_db: AsyncSession
):
"""User A gets 404 (not 403) when accessing User B's AI session."""
from app.models.ai_session import AISession
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "t6-ais-a")
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "t6-ais-b")
ai_session_b = AISession(
user_id=user_b.id,
account_id=acct_b.id,
problem_summary="Test session",
problem_domain="networking",
status="active",
)
test_db.add(ai_session_b)
await test_db.commit()
headers_a = await _login(client, user_a.email, pass_a)
resp = await client.get(f"/api/v1/ai-sessions/{ai_session_b.id}", headers=headers_a)
assert resp.status_code == 404, (
f"Expected 404 for cross-user AI session access, got {resp.status_code}"
)
@pytest.mark.asyncio
async def test_ai_session_retry_psa_push_requires_ownership(
client: AsyncClient, test_db: AsyncSession
):
"""User A cannot retry PSA push for User B's AI session."""
from app.models.ai_session import AISession
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "t6-psa-a")
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "t6-psa-b")
ai_session_b = AISession(
user_id=user_b.id,
account_id=acct_b.id,
problem_summary="PSA test",
problem_domain="networking",
status="resolved",
)
test_db.add(ai_session_b)
await test_db.commit()
headers_a = await _login(client, user_a.email, pass_a)
resp = await client.post(
f"/api/v1/ai-sessions/{ai_session_b.id}/retry-psa-push",
headers=headers_a,
)
assert resp.status_code == 404, (
f"Expected 404 for cross-user retry-psa-push, got {resp.status_code}"
)
@pytest.mark.asyncio
async def test_upload_url_returns_404_not_403_for_other_account(
client: AsyncClient, test_db: AsyncSession
):
"""User A gets 404 (not 403) when accessing User B's upload URL."""
from app.models.file_upload import FileUpload
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "t6-upl-a")
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "t6-upl-b")
upload_b = FileUpload(
account_id=acct_b.id,
uploaded_by=user_b.id,
filename="secret.png",
content_type="image/png",
size_bytes=1024,
storage_key="test/secret.png",
)
test_db.add(upload_b)
await test_db.commit()
headers_a = await _login(client, user_a.email, pass_a)
resp = await client.get(f"/api/v1/uploads/{upload_b.id}/url", headers=headers_a)
assert resp.status_code in (404, 503), (
f"Expected 404 (or 503 if storage not configured) for cross-account upload, got {resp.status_code}"
)
@pytest.mark.asyncio
async def test_share_revoke_returns_404_not_403_for_other_user(
client: AsyncClient, test_db: AsyncSession
):
"""User A gets 404 (not 403) when revoking User B's share."""
from app.models.session import Session
from app.models.session_share import SessionShare
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "t6-shr-a")
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "t6-shr-b")
tree_b = await _create_private_tree(test_db, acct_b, user_b)
session_b = Session(
tree_id=tree_b.id,
user_id=user_b.id,
tree_snapshot={"id": "root", "type": "start", "children": []},
path_taken=[],
decisions=[],
)
test_db.add(session_b)
await test_db.flush()
share_b = SessionShare(
session_id=session_b.id,
account_id=acct_b.id,
share_token="test-token-unique-" + uuid.uuid4().hex[:8],
share_name="Test",
visibility="public",
created_by=user_b.id,
)
test_db.add(share_b)
await test_db.commit()
headers_a = await _login(client, user_a.email, pass_a)
resp = await client.delete(f"/api/v1/shares/{share_b.id}", headers=headers_a)
assert resp.status_code == 404, (
f"Expected 404 for cross-user share revoke, got {resp.status_code}"
)
# ── Task 6 (continued): steps, tags, step_categories, maintenance_schedules ──
@pytest.mark.asyncio
async def test_cannot_access_other_account_step(
client: AsyncClient, test_db: AsyncSession
):
"""User A gets 404 when reading a team-visibility step owned by Account B."""
from app.models.step_library import StepLibrary
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "t6-step-a")
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "t6-step-b")
# Create a team-visibility step owned by account B
step_b = StepLibrary(
title="Account B Confidential Step",
step_type="action",
content={"description": "secret step"},
created_by=user_b.id,
account_id=acct_b.id,
visibility="team",
is_active=True,
)
test_db.add(step_b)
await test_db.commit()
headers_a = await _login(client, user_a.email, pass_a)
resp = await client.get(f"/api/v1/steps/{step_b.id}", headers=headers_a)
assert resp.status_code == 404, (
f"Expected 404 for cross-account step access, got {resp.status_code}: {resp.text}"
)
@pytest.mark.asyncio
async def test_cannot_access_other_account_tag(
client: AsyncClient, test_db: AsyncSession
):
"""User A gets 404 when reading a tag scoped to Account B."""
from app.models.tag import TreeTag
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "t6-tag-a")
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "t6-tag-b")
# Create an account-scoped tag for account B
tag_b = TreeTag(
name=f"account-b-tag-{uuid.uuid4().hex[:6]}",
slug=f"account-b-tag-{uuid.uuid4().hex[:6]}",
account_id=acct_b.id,
)
test_db.add(tag_b)
await test_db.commit()
headers_a = await _login(client, user_a.email, pass_a)
resp = await client.get(f"/api/v1/tags/{tag_b.id}", headers=headers_a)
assert resp.status_code == 404, (
f"Expected 404 for cross-account tag access, got {resp.status_code}: {resp.text}"
)
@pytest.mark.asyncio
async def test_cannot_access_other_account_step_category(
client: AsyncClient, test_db: AsyncSession
):
"""User A gets 404 when reading a step category scoped to Account B."""
from app.models.step_category import StepCategory
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "t6-scat-a")
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "t6-scat-b")
# Create an account-scoped step category for account B
category_b = StepCategory(
name=f"Account B Category {uuid.uuid4().hex[:6]}",
slug=f"account-b-cat-{uuid.uuid4().hex[:6]}",
account_id=acct_b.id,
is_active=True,
)
test_db.add(category_b)
await test_db.commit()
headers_a = await _login(client, user_a.email, pass_a)
resp = await client.get(f"/api/v1/step-categories/{category_b.id}", headers=headers_a)
assert resp.status_code == 404, (
f"Expected 404 for cross-account step category access, got {resp.status_code}: {resp.text}"
)
@pytest.mark.asyncio
async def test_maintenance_schedule_returns_404_for_other_team(
client: AsyncClient, test_db: AsyncSession
):
"""User A gets 404 when reading a maintenance schedule belonging to Team B's tree."""
from app.models.team import Team
from app.models.maintenance_schedule import MaintenanceSchedule
# Create two separate teams
team_a = Team(name="Team A Corp")
team_b = Team(name="Team B Corp")
test_db.add_all([team_a, team_b])
await test_db.flush()
# Create accounts and users, assign to respective teams
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "t6-ms-a")
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "t6-ms-b")
user_a.team_id = team_a.id
user_b.team_id = team_b.id
await test_db.flush()
# Create a maintenance tree owned by team B
tree_b = Tree(
name="Team B Maintenance Flow",
account_id=acct_b.id,
author_id=user_b.id,
team_id=team_b.id,
visibility="team",
tree_type="maintenance",
tree_structure={"id": "root", "type": "start", "children": []},
is_active=True,
status="published",
)
test_db.add(tree_b)
await test_db.flush()
# Create a schedule for that tree
schedule_b = MaintenanceSchedule(
tree_id=tree_b.id,
created_by=user_b.id,
cron_expression="0 2 * * 0",
timezone="UTC",
is_active=True,
next_run_at=dt.datetime(2026, 12, 31, tzinfo=dt.timezone.utc),
)
test_db.add(schedule_b)
await test_db.commit()
headers_a = await _login(client, user_a.email, pass_a)
resp = await client.get(f"/api/v1/maintenance-schedules/tree/{tree_b.id}", headers=headers_a)
assert resp.status_code == 404, (
f"Expected 404 for cross-team maintenance schedule access, got {resp.status_code}: {resp.text}"
)
@pytest.mark.asyncio
async def test_get_documentation_returns_404_for_other_user_session(
client: AsyncClient, test_db: AsyncSession
):
"""GET /ai-sessions/{id}/documentation must return 404 (not 403) for cross-user access."""
from app.models.ai_session import AISession
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "doc-a")
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "doc-b")
session_b = AISession(
user_id=user_b.id,
account_id=acct_b.id,
problem_summary="B's confidential session",
problem_domain="networking",
status="resolved",
)
test_db.add(session_b)
await test_db.commit()
headers_a = await _login(client, user_a.email, pass_a)
resp = await client.get(
f"/api/v1/ai-sessions/{session_b.id}/documentation",
headers=headers_a,
)
assert resp.status_code == 404, f"Expected 404, got {resp.status_code}: {resp.text}"

View File

@@ -447,3 +447,55 @@ class TestVisibilityFilter:
assert "author_name" in trees[0]
# visibility key should be present
assert "visibility" in trees[0]
@pytest.mark.asyncio
async def test_get_tree_returns_404_not_403_for_other_account_tree(
self, client: AsyncClient, auth_headers: dict, test_db: AsyncSession
):
"""Account A must not learn that Account B's private tree exists."""
from app.models.tree import Tree
from app.models.account import Account
from app.models.user import User
from app.core.security import get_password_hash
import uuid
# Create a second account and user
account_b = Account(name="Other Corp", display_code="OTH00001")
test_db.add(account_b)
await test_db.flush()
user_b = User(
email=f"user-b-{uuid.uuid4().hex[:6]}@example.com",
name="User B",
password_hash=get_password_hash("TestPass123!"),
is_active=True,
account_id=account_b.id,
account_role="engineer",
)
test_db.add(user_b)
await test_db.flush()
# Create a private tree belonging to account_b
private_tree = Tree(
name="Secret Tree",
account_id=account_b.id,
author_id=user_b.id,
visibility="private",
tree_type="troubleshooting",
tree_structure={"id": "root", "type": "start", "children": []},
is_active=True,
is_default=False,
is_public=False,
status="published",
)
test_db.add(private_tree)
await test_db.commit()
response = await client.get(
f"/api/v1/trees/{private_tree.id}",
headers=auth_headers,
)
assert response.status_code == 404, (
f"Expected 404 but got {response.status_code}"
"leaking tree existence to wrong tenant"
)

View File

@@ -0,0 +1,363 @@
# MSP Assistant Harness — Design Spec
**Date:** 2026-04-01
**Status:** Draft — pending user review
**Source:** MSP_Assistant_Harness_Implementation_Plan.docx (v2.0, March 2026) + brainstorming session
---
## Context
The `/assistant` page currently works as a generic AI chat surface with a task lane side panel and a chat sidebar for session history. It functions well but reads as "AI chat with extras" rather than an MSP engineer's operational tool.
The goal is to reframe the page as a **live triage cockpit** — the place where an engineer opens a ticket, works through it from intake to resolution, and closes with a structured handoff artifact. The underlying session, branching, and chat architecture is preserved. What changes is layout hierarchy, information density, field labelling, and the conclude output.
Scope is broader than the original docx: includes all required backend changes to support the frontend properly.
---
## Design Decisions
### 1. Overall Layout — Stacked Zones
```
┌─────────────────────────────────────────────┐
│ Incident Header (labelled fields, 1 row) │
├────────────────────────┬────────────────────┤
│ │ FlowPilot Asks │
│ Steps Checklist │ (quick replies) │
│ (left, ~55%) ├────────────────────┤
│ │ What We Know │
│ │ (evidence list) │
├────────────────────────┴────────────────────┤ ← drag handle
│ Conversation Log (muted, darker bg) │
├─────────────────────────────────────────────┤
│ Compose area │
└─────────────────────────────────────────────┘
```
- Work zone (top) and conversation log (bottom) are **drag-resizable** via a handle
- Default split: ~55% work zone, ~45% chat
- Existing left sidebar (session history) unchanged
- Compose area is always pinned to bottom, spans full width
- `workZoneHeight` persisted to `localStorage` so split survives refresh
### 2. Incident Header
Single row with explicit micro-labels above each field:
```
CLIENT DEVICE CATEGORY HYPOTHESIS
Contoso Ltd ✏ jsmith-desktop ✏ DNS / Network ✏ Corrupted DNS cache on NIC ✏
[CW #48291] [Resolve ▾] [⋯]
```
- Each field has its own `✏` icon (visible on hover) that opens an inline edit popover
- Fields populate from: (a) intake form on session create, (b) AI-inferred updates mid-session via `triage_update`, (c) manual engineer edits via `PATCH /ai-sessions/{id}/triage`
- PSA ticket number shown if linked; action buttons (Resolve, overflow menu) on the right
- Empty fields show muted placeholder text — never blank
### 3. Work Zone — Steps + FlowPilot Asks + What We Know
**Left panel (~55%): ordered step checklist**
- Steps displayed as a vertical list: `✓` completed, `→` active (blue border, white text), `○` pending
- Active step is visually distinct
- "Generate Script" CTA appears at the bottom when a script-generation step is active
**Right panel (~45%): two stacked mini-panels**
- **FlowPilot Asks** (top, amber label): current question from AI. When `options` are provided, renders as quick-reply buttons — clicking a button submits that answer as a chat message. When no `options`, renders a compact free-text input. Panel is empty/hidden when no pending question.
- **What We Know** (bottom, muted label): running evidence list. Each entry: `✓ confirmed` / `✗ ruled out` / `? pending`. AI appends via `triage_update.evidence_items`; engineer can manually add or edit entries.
### 4. Conversation Log Zone
- Lives below the work zone, separated by a **drag handle**
- Background: `#13151c` (one step darker than page) — visually recedes
- Label: "CONVERSATION LOG" in muted colour (`text-muted`)
- Messages are compact: `you:` / `fp:` prefixes instead of full name/avatar bubbles
- Scrolls independently
- Not collapsible — drag handle gives control
### 5. Conclude / Handoff Modal (redesigned)
On opening "Close Case":
1. **Header**: "Close Case — [Client Name]" + outcome selector (Resolved / Escalated / Parked)
2. **Structured fields** — pre-filled by streaming `/handoff-draft`, all editable:
- **Root Cause** (short text input)
- **Resolution** (what fixed it)
- **Steps Taken** (list, auto-populated from step checklist)
- **Recommendations** (next steps / preventive actions)
3. **Output destinations** (checkboxes): Post to CW ticket note / Save to Knowledge Base / Send client summary
4. **Confirm** button — triggers resolve/escalate/pause and passes structured fields into `ResolutionOutputGenerator`
The existing `SessionResolutionOutput` model and `ResolutionOutputGenerator` service are reused. The `/handoff-draft` stream starts immediately on modal open — the engineer can begin editing while fields fill in.
---
## Backend Changes Required
### 1. New AISession columns (Alembic migration)
Add to `ai_sessions` table:
| Column | Type | Purpose |
|--------|------|---------|
| `client_name` | `VARCHAR(255)` | MSP client name for incident header |
| `asset_name` | `VARCHAR(255)` | Device / asset / user being worked on |
| `issue_category` | `VARCHAR(100)` | Human-readable category (e.g. "DNS / Networking") |
| `triage_hypothesis` | `TEXT` | Current working hypothesis — AI-updated + engineer-editable |
| `evidence_items` | `JSONB` | "What We Know" list — persisted for session resume |
`evidence_items` format: `[{ "text": str, "status": "confirmed" | "ruled_out" | "pending" }]`
Note: `problem_domain` (existing) is an internal classifier slug. `issue_category` is the human-readable display label for the header. Both coexist.
### 2. New PATCH endpoint — triage metadata
```
PATCH /ai-sessions/{session_id}/triage
Auth: require_engineer_or_admin
Body: { client_name?, asset_name?, issue_category?, triage_hypothesis?, evidence_items? }
Response: { id, client_name, asset_name, issue_category, triage_hypothesis, evidence_items }
```
Used when the engineer edits any header field or evidence list manually.
### 3. Updated schemas — TriageUpdate and QuestionItem.options
**New `TriageUpdate` model** (returned in chat response when AI infers session context):
```python
class TriageUpdate(BaseModel):
client_name: str | None = None
asset_name: str | None = None
issue_category: str | None = None
triage_hypothesis: str | None = None
evidence_items: list[dict] | None = None # appends to existing list
```
**Updated `ChatMessageResponse`:**
```python
class ChatMessageResponse(BaseModel):
# existing fields unchanged...
triage_update: TriageUpdate | None = None
```
**Updated `QuestionItem`** — add `options` for quick-reply buttons:
```python
class QuestionItem(BaseModel):
text: str
context: str = ""
options: list[str] | None = None # quick-reply labels; null = free-text fallback
```
### 4. unified_chat_service.py — triage extraction
After generating each AI response, run a lightweight extraction to populate `triage_update`. Implementation options (pick one during implementation):
- **Option A (recommended):** Embed structured extraction in the system prompt using an `[TRIAGE_UPDATE]` marker, similar to existing `[QUESTIONS]` / `[ACTIONS]` markers. AI emits the block if it has new triage signals; service parses it.
- **Option B:** Post-response extraction pass using a fast model (`claude-haiku-4-5`) with the last 3 messages as context.
When `triage_update` contains non-null fields, the service auto-PATCHes the session record (so fields are persisted) AND returns `triage_update` in the response for the frontend to update the header immediately.
### 5. New streaming endpoint — handoff draft
```
POST /ai-sessions/{session_id}/handoff-draft
Auth: require_engineer_or_admin
Response: StreamingResponse (text/event-stream)
```
Streams a structured handoff JSON object:
```json
{ "root_cause": "...", "resolution": "...", "steps_taken": ["..."], "recommendations": "..." }
```
Built from session context: `problem_summary`, `triage_hypothesis`, `evidence_items`, `conversation_messages` (last 20), step checklist from saved task lane state.
### 6. Updated conclude schemas
Add optional structured fields to `ResolveSessionRequest` and `EscalateSessionRequest`:
```python
root_cause: str | None = None
steps_taken: list[str] | None = None
recommendations: str | None = None
```
Pass these into `ResolutionOutputGenerator._build_session_context()` to enrich `psa_ticket_notes` and `client_summary` outputs.
### 7. Session read endpoint — include new triage fields
Ensure the session detail response (`GET /ai-sessions/{id}`) returns the new fields so the frontend can restore header state on session resume.
---
## Frontend Changes Required
### 1. AssistantChatPage layout refactor
Replace current layout (sidebar + chat column + TaskLane side panel) with the stacked cockpit layout described above.
**New state:**
- `triageMeta: TriageMeta``{ client_name, asset_name, issue_category, triage_hypothesis, evidence_items }`
- `workZoneHeight: number` — persisted to `localStorage('rf-assistant-work-zone-height')`
**On session load / resume:** populate `triageMeta` from the session response (new fields).
**On AI response:** if `response.triage_update` is non-null, merge into `triageMeta` (partial update, preserve existing non-null values unless AI overwrites).
### 2. New component: `IncidentHeader`
```
frontend/src/components/assistant/IncidentHeader.tsx
```
Props: `triageMeta`, `psaTicketId`, `sessionId`, `onFieldSave(field, value)`, `onResolve`, `onOverflow`
- Renders labelled single-row header
- Each field: micro-label + value + `✏` icon (visible on hover)
- `✏` opens an `EditPopover` (small popover with text input + Save/Cancel)
- On Save: calls `aiSessionsApi.updateTriage(sessionId, { [field]: value })`
- Empty field shows muted placeholder (e.g. "Unknown client")
### 3. Refactored component: `StepsPanel` (from TaskLane)
```
frontend/src/components/assistant/StepsPanel.tsx
```
Same `activeActions` data source. Renders as ordered checklist:
- Completed: `✓` + strikethrough label, muted
- Active: `→` + blue left border, white text, full opacity
- Pending: `○` + muted text
Script generation CTA: shown at bottom when the active step has `command` containing "script" or when AI has flagged it.
### 4. New component: `FlowPilotAsks`
```
frontend/src/components/assistant/FlowPilotAsks.tsx
```
Props: `questions: QuestionItem[]`, `onAnswer(answer: string)`
- Shows first unanswered question (or empty/hidden state if none)
- When `question.options` is non-null: renders as button row, clicking calls `onAnswer(option)`
- When `question.options` is null: renders compact text input with Send button
- `onAnswer` calls `handleSend` in the parent page with the answer text
### 5. New component: `WhatWeKnow`
```
frontend/src/components/assistant/WhatWeKnow.tsx
```
Props: `items: EvidenceItem[]`, `onAdd(text, status)`, `onEdit(index, text, status)`
- Renders evidence list with status icons: `✓` (confirmed, green), `✗` (ruled out, red), `?` (pending, muted)
- "+ Add finding" link at bottom opens an inline input row
- Items are editable inline (click to edit)
- State lives in `AssistantChatPage` as part of `triageMeta.evidence_items`, synced to backend via `PATCH /triage`
### 6. Drag handle — resizable split
Implement as a thin handle bar between work zone and conversation log. On drag:
- Update `workZoneHeight` in state
- Persist to `localStorage`
On mount: restore from `localStorage`, default to `55%` of available height.
### 7. Compact conversation log
Replace current `<ChatMessage>` bubble rendering in the log zone with a compact list:
```
you: Can't resolve external DNS, internal fine
fp: Ping passed — layer 3 OK. Run nslookup google.com.
you: Timed out on 1.1.1.1 too.
```
`ChatMessage` component still used for rich rendering (suggested flows, forks) but in a more compact variant. Full bubble rendering available on hover/expand if needed.
### 8. Redesigned `ConcludeSessionModal`
Replaces current simple textarea with the structured handoff form. On open:
1. Call `aiSessionsApi.getHandoffDraft(sessionId)` — streaming — populate fields as stream arrives
2. Render outcome selector + 4 structured fields (all `<textarea>` with labels)
3. Render output destination checkboxes
4. On Confirm: call resolve/escalate/pause with enriched request body
### 9. MSP-native language pass
| Old | New |
|-----|-----|
| "AI Assistant" | "FlowPilot" |
| "New Chat" | "New Case" |
| "Messages" | "Conversation Log" |
| "Task Lane" (panel header) | "Steps" |
| "Conclude" | "Close Case" |
| "Chat history" (sidebar label) | "Case History" |
---
## What This Is NOT
- Not a redesign of the FlowPilot session page (`/pilot`) — separate page, untouched
- Not a rebuild of session, branching, or PSA architecture
- Not a new data model for conversations — `conversation_messages` JSONB is unchanged
- Not a mobile-first redesign — mobile degrades cleanly but desktop is primary
---
## Verification
### Harness Feel Test (primary — subjective)
- Open `/assistant`, start a new case: does the page read as an MSP triage cockpit within 3 seconds without reading labels?
- Is the current active step obvious without scrolling through chat?
- Do FlowPilot Asks quick-reply buttons submit answers and update the steps list?
- Does the incident header update mid-session as the AI infers context?
- Drag the handle, refresh: does the split restore correctly?
### Functional Regression
- Free-text chat, image paste, suggested flows, forks, branching: all work
- Session pause, resume, and handoff end-to-end: works
- ConcludeSessionModal resolves / escalates / parks correctly
- Handoff draft streams and pre-fills the modal fields
- Manual header edit saves and persists across reload
### MSP Scenario Coverage (from docx)
Run end-to-end: single-user endpoint issue · M365/tenant-wide issue · network/VPN outage · escalation and resume after handoff.
### Backend Checks
```bash
# Migration
alembic upgrade head
# Verify new columns
psql -U postgres -d resolutionflow -c "\d ai_sessions" | grep -E "client_name|asset_name|issue_category|triage_hypothesis|evidence_items"
# Smoke test endpoints (with valid token)
curl -X PATCH .../ai-sessions/{id}/triage -d '{"client_name":"Test"}'
curl -X POST .../ai-sessions/{id}/handoff-draft # should stream JSON
```
---
## Critical Files
| File | Change |
|------|--------|
| `backend/app/models/ai_session.py` | Add 5 new columns |
| `backend/app/schemas/ai_session.py` | Add `TriageUpdate`, extend `QuestionItem`, update request/response schemas |
| `backend/app/api/endpoints/ai_sessions.py` | Add `PATCH /{id}/triage`, `POST /{id}/handoff-draft` |
| `backend/app/services/unified_chat_service.py` | Extract and return `triage_update` per AI response |
| `backend/app/services/resolution_output_generator.py` | Accept structured handoff fields in context builder |
| `backend/alembic/versions/NNN_add_triage_fields_to_ai_sessions.py` | Sequential migration (check `ls backend/alembic/versions/ \| sort \| tail -1` for NNN) |
| `frontend/src/pages/AssistantChatPage.tsx` | Full layout refactor — cockpit structure |
| `frontend/src/components/assistant/IncidentHeader.tsx` | New component |
| `frontend/src/components/assistant/StepsPanel.tsx` | Refactored from `TaskLane` |
| `frontend/src/components/assistant/FlowPilotAsks.tsx` | New component |
| `frontend/src/components/assistant/WhatWeKnow.tsx` | New component |
| `frontend/src/components/assistant/ConcludeSessionModal.tsx` | Redesigned |
| `frontend/src/api/aiSessions.ts` | Add `updateTriage()`, `getHandoffDraft()` |
| `frontend/src/types/ai-session.ts` | Add `TriageUpdate`, `TriageMeta`, `EvidenceItem`; extend `QuestionItem` |

View File

@@ -0,0 +1,609 @@
# MSP Assistant Harness — Super Plan
**Date:** 2026-04-01
**Status:** Approved — ready to execute
**Sources:** `MSP_Assistant_Harness_Implementation_Plan.docx` (v2.0) + `2026-04-01-msp-assistant-harness-design.md` (brainstorming session)
---
## Goal
Reframe `/assistant` from a generic AI chat surface into a **live MSP triage cockpit**. An engineer arrives with an open ticket; the page immediately reads as their operational tool — not an AI chatbot that's been adapted for IT work.
The change is a UI and data layer reframe. The existing session, branching, PSA, and conclude architecture is preserved and extended, not rebuilt.
### Key Architectural Choices
This plan explicitly chooses:
- **`FlowPilot`** as the primary page/product label (not "Assistant Harness")
- **Backend triage + handoff contracts required in v1** — not deferred to a later phase
- **Desktop-first cockpit layout** with clean mobile degradation
- **Explicit persisted triage fields** on the session model, not purely derived/computed header state
- **Prompt-embedded structured extraction** (`[TRIAGE_UPDATE]` marker) as the primary AI triage path, with post-response model pass only as fallback
- **Sidebar visual demotion** — existing sidebar stays but is visually de-emphasized so the cockpit reads as an operations surface, not a chat app
---
## What Phase 0 Resolved
The brainstorming session (2026-04-01) locked these decisions. They are not open questions.
| Question | Decision |
|----------|----------|
| Layout structure | Stacked zones: incident header → work zone → (drag handle) → conversation log → compose |
| Incident header style | Single row, explicit micro-labels above each field, per-field `✏` edit |
| Work zone left panel | Ordered step checklist (✓ / → / ○) |
| Work zone right panel | Two stacked mini-panels: FlowPilot Asks (top) + What We Know (bottom) |
| Chat zone treatment | Drag-resizable split, compact `you:` / `fp:` prefix style, darker background |
| Chat collapsibility | Not collapsible — drag handle gives control |
| Scope | Includes all required backend changes, not UI-layer only |
| Conclude modal | Fully redesigned as structured handoff artifact |
| Page label | "FlowPilot" (not "AI Assistant") |
| "New Chat" label | "New Case" |
| "Conclude" label | "Close Case" |
| Hypothesis language | "Hypothesis" (direct, not softened to "working theory") |
| What We Know editability | Engineer-editable + AI-appended |
| Header field population | Intake form + AI-inferred mid-session + manual engineer override |
---
## Cockpit Layout
```
┌─────────────────────────────────────────────────────────────┐
│ [Left sidebar — Case History, unchanged] │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ INCIDENT HEADER (single row, labelled fields) │ │
│ │ CLIENT DEVICE CATEGORY HYPOTHESIS │ │
│ │ Contoso ✏ jsmith-04 ✏ DNS/Net ✏ Cache fail ✏ │ │
│ │ [CW #48291][Resolve⋯]│ │
│ ├───────────────────────┬───────────────────────────────┤ │
│ │ │ ▸ FLOWPILOT ASKS (amber) │ │
│ │ STEPS (~55%) │ Did nslookup time out? │ │
│ │ ✓ Ping 8.8.8.8 │ [Time out] [Wrong IP] [Both] │ │
│ │ → nslookup ←active ├───────────────────────────────┤ │
│ │ ○ Flush DNS │ WHAT WE KNOW │ │
│ │ ○ Check NIC │ ✓ Gateway reachable │ │
│ │ │ ✗ DNS 1.1.1.1 — timeout │ │
│ │ [⚡ Generate Script] │ ? DNS 8.8.8.8 — pending │ │
│ ├───────────────────────┴───── ≡ drag handle ───────────┤ │
│ │ CONVERSATION LOG (compact, darker bg) │ │
│ │ you: Can't resolve external DNS, internal fine │ │
│ │ fp: Ping test passed. Run nslookup google.com. │ │
│ │ you: Timed out on 1.1.1.1 too. │ │
│ ├───────────────────────────────────────────────────────┤ │
│ │ Describe next finding or ask FlowPilot... [Send] │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## Contract Decisions (Codex Readiness Review)
The following decisions were flagged as ambiguous by the Codex readiness review. Each is now resolved.
### RED — Canonical handoff artifact
**Decision:** `ResolutionOutputGenerator` is the single canonical generator. Everything else is transport or UI.
- `POST /handoff-draft` is a **preview** endpoint — streams a draft for the conclude modal UI. Does not persist. Does not generate final artifacts.
- On confirm (resolve/escalate), the page calls the existing resolve/escalate endpoints, which trigger `ResolutionOutputGenerator.generate_all()` as today. The structured fields from the modal (`root_cause`, `steps_taken`, `recommendations`) are passed into `_build_session_context()` to enrich the final outputs.
- `/documentation/stream` and `/status-update` remain untouched — they are separate transport channels for the same canonical outputs.
- `/handoff-draft` is **assistant-only** in v1 (not shared with guided FlowPilot sessions on `/pilot`).
### RED — AI vs manual field authority
**Decision:** Manual edits win. AI does not overwrite manual edits.
| Rule | Behavior |
|------|----------|
| AI auto-fill | Only fills fields that are currently `null` or empty. Never overwrites a non-null value. |
| Manual edit | Persists immediately via `PATCH /triage`. Sets the field as "manually set." |
| AI after manual edit | AI may **suggest** an update (shown as a subtle inline prompt: "FlowPilot suggests: Contoso Corp → Contoso Ltd"), but does not auto-write. |
| Evidence items — AI | Appends new items only. Does not modify or remove existing items. |
| Evidence items — engineer | Full authority: add, edit status, edit text, remove. |
Implementation: add a `triage_manual_fields` set (stored in frontend `localStorage` per session) tracking which fields the engineer has manually edited. AI `triage_update` skips those fields unless the engineer explicitly accepts the suggestion.
### RED — `evidence_items` write model
**Decision:** Full-list replacement for all writes. Keep it simple.
- `PATCH /triage` sends the complete `evidence_items` array. Backend replaces the stored array.
- AI appends: frontend receives `triage_update.evidence_items`, appends to the current local list, then PATCHes the full merged list.
- Engineer edits: frontend modifies the local list, PATCHes the full list.
- No partial-update or append-only semantics on the backend. The frontend is the merge authority.
### YELLOW — TaskLane persistence in StepsPanel
**Decision:** `StepsPanel` is presentation only. All persistence behavior stays in `AssistantChatPage`.
`TaskLane` currently owns sessionStorage drafts, debounced backend saves, and restoration. In the cockpit refactor:
- `AssistantChatPage` lifts all persistence logic out of `TaskLane` into the page (or a custom hook like `useTaskPersistence`)
- `StepsPanel` receives `activeActions` as a prop and renders them — no persistence responsibility
- `TaskLane.tsx` remains in the codebase untouched (other pages may still use it)
### YELLOW — Quick-reply submission semantics
**Decision:** Quick replies are **immediate-send** controls.
- Clicking a quick-reply button calls `handleSend(option)` — the answer goes directly to the AI as a chat message
- No local-only "select then send" workflow
- The answer appears in the conversation log as a regular `you:` message
- This is a full-stack change: prompt instructions must tell the AI to include `options` on constrained questions, parser must extract them, schema must carry them, frontend must render and submit them
### YELLOW — `issue_category` format
**Decision:** Free text in v1. No controlled taxonomy.
- AI infers a human-readable category string (e.g., "DNS / Networking", "Microsoft 365", "Active Directory")
- Engineer can edit to any value via the header `✏` popover
- Future: may introduce a taxonomy dropdown populated from session history — but not in v1
### YELLOW — `asset_name` when user and device differ
**Decision:** Free text. The engineer enters whatever is most operationally relevant.
- Could be a device name ("jsmith-desktop-04"), a user ("John Smith"), or both ("jsmith-desktop-04 / John Smith")
- AI infers from conversation context — typically the entity being troubleshot
- No enforced format in v1
### YELLOW — Structured conclude fields persistence
**Decision:** Structured conclude fields (`root_cause`, `steps_taken`, `recommendations`) are **passed through to `ResolutionOutputGenerator`** but are NOT stored as separate session columns.
- They arrive in the resolve/escalate request body
- `_build_session_context()` uses them to generate richer PSA notes and client summaries
- The generated outputs (stored in `session_resolution_outputs`) are the persisted artifacts
- If we later need the raw structured fields, add columns then — not speculatively now
### Fallback — `[TRIAGE_UPDATE]` unreliability
**Decision:** If prompt-embedded extraction proves unreliable after testing against 5 real sessions:
1. **First fallback:** Post-response extraction using `claude-haiku-4-5` with last 3 messages as context. Cheap, fast, decoupled from the main prompt.
2. **Second fallback:** Fully manual header — engineer fills in fields, AI never auto-updates. Cockpit still works; it just requires more manual input.
Gate: Phase 2 step 15 ("verify extraction in a live session") must pass before wiring `triage_update` into the visible header.
---
## Implementation Guardrails
These are hard rules during implementation, not suggestions.
1. **Do not let AI write speculative values into the header.** Every AI-inferred field must trace to ticket data or explicit conversation evidence. If the AI can't ground it, the field stays empty.
2. **Do not redesign conclude UX until the canonical handoff source-of-truth is wired.** Phase 6 (conclude modal) depends on Phase 1 (backend) being stable.
3. **Do not treat `TaskLane` as presentation-only until its persistence behavior has been lifted.** Extract persistence into a hook or the page before building `StepsPanel`.
4. **Do not wire header auto-updates from `[TRIAGE_UPDATE]` until real-session reliability is tested.** Phase 2 step 15 is a gate.
5. **Run `npx tsc -b` after every phase.** Do not batch TypeScript error fixes (lesson #92).
---
## Non-Goals
- No redesign of `/pilot` (FlowPilot session page) — separate page, untouched
- No rebuild of session, branching, or PSA architecture
- No new data model for conversations — `conversation_messages` JSONB unchanged
- No mobile-first redesign — mobile degrades cleanly, desktop is primary
- No generic "assistant polish" that does not tighten the harness
---
## Backend Changes
### B1 — Alembic migration `071`
File: `backend/alembic/versions/071_add_triage_fields_to_ai_sessions.py`
Add to `ai_sessions`:
| Column | Type | Notes |
|--------|------|-------|
| `client_name` | `VARCHAR(255)` | MSP client for incident header |
| `asset_name` | `VARCHAR(255)` | Device / user being worked on |
| `issue_category` | `VARCHAR(100)` | Human-readable category ("DNS / Networking") |
| `triage_hypothesis` | `TEXT` | Working hypothesis — AI-updated + editable |
| `evidence_items` | `JSONB` | What We Know list — persisted for resume |
`evidence_items` schema: `[{ "text": str, "status": "confirmed" | "ruled_out" | "pending" }]`
Note: existing `problem_domain` is an internal classifier slug and is unchanged. `issue_category` is the human-readable display label. Both coexist.
### B2 — Updated schemas (`backend/app/schemas/ai_session.py`)
**New `TriageUpdate`:**
```python
class TriageUpdate(BaseModel):
client_name: str | None = None
asset_name: str | None = None
issue_category: str | None = None
triage_hypothesis: str | None = None
evidence_items: list[dict] | None = None # appends to existing list
```
**Updated `ChatMessageResponse`:**
```python
class ChatMessageResponse(BaseModel):
# ... existing fields unchanged ...
triage_update: TriageUpdate | None = None
```
**Updated `QuestionItem`** — add quick-reply options:
```python
class QuestionItem(BaseModel):
text: str
context: str = ""
options: list[str] | None = None # quick-reply labels; null → free-text input
```
**Updated `ResolveSessionRequest` / `EscalateSessionRequest`:**
```python
root_cause: str | None = None
steps_taken: list[str] | None = None
recommendations: str | None = None
```
### B3 — New `PATCH /ai-sessions/{id}/triage` endpoint
```
PATCH /ai-sessions/{session_id}/triage
Auth: require_engineer_or_admin
Body: { client_name?, asset_name?, issue_category?, triage_hypothesis?, evidence_items? }
Response: { id, client_name, asset_name, issue_category, triage_hypothesis, evidence_items }
```
Called on every manual header field edit. Partial update — only supplied fields are written.
### B4 — New `POST /ai-sessions/{id}/handoff-draft` endpoint
```
POST /ai-sessions/{session_id}/handoff-draft
Auth: require_engineer_or_admin
Response: StreamingResponse (text/event-stream)
```
Streams structured handoff JSON built from session context:
```json
{ "root_cause": "...", "resolution": "...", "steps_taken": ["..."], "recommendations": "..." }
```
Uses: `problem_summary`, `triage_hypothesis`, `evidence_items`, last 20 `conversation_messages`, saved task lane state.
Called immediately on conclude modal open — engineer can edit while stream fills in.
### B5 — `unified_chat_service.py` — triage extraction
After each AI response, extract triage signals and return as `triage_update`.
**Recommended approach:** Add a `[TRIAGE_UPDATE]` structured marker to the system prompt, following the existing `[QUESTIONS]` / `[ACTIONS]` / `[FORK]` marker pattern. The AI emits the block only when it has new signal:
```
[TRIAGE_UPDATE]
client_name: Contoso Ltd
issue_category: DNS / Networking
triage_hypothesis: Corrupted DNS cache on NIC
evidence_items:
- confirmed: Gateway 192.168.1.1 reachable
- ruled_out: DNS 1.1.1.1 — timeout
[/TRIAGE_UPDATE]
```
Service parses this, strips it from `display_content`, auto-PATCHes the session record, and returns `triage_update` in the response.
### B6 — `resolution_output_generator.py` — accept structured fields
Update `_build_session_context()` to incorporate `root_cause`, `steps_taken`, and `recommendations` when supplied, producing richer `psa_ticket_notes` and `client_summary` outputs.
### B7 — Session detail response — expose new triage fields
`GET /ai-sessions/{id}` (and the session list item) must return the 5 new fields so the frontend can restore header state on session load and resume.
---
## Frontend Changes
### F1 — `AssistantChatPage.tsx` — cockpit layout refactor
Replace current layout (sidebar + chat column + TaskLane right rail) with the stacked cockpit structure.
**New state:**
- `triageMeta: TriageMeta``{ client_name, asset_name, issue_category, triage_hypothesis, evidence_items }`
- `workZoneHeight: number` — persisted to `localStorage('rf-assistant-work-zone-height')`
**On session load / resume:** populate `triageMeta` from session response new fields.
**On AI response:** if `response.triage_update` is non-null, merge into `triageMeta` (partial — preserve existing non-null values unless AI explicitly overwrites).
**Work zone layout:** left `StepsPanel` + right column with `FlowPilotAsks` stacked above `WhatWeKnow`.
**Chat zone layout:** compact `ConversationLog` below drag handle, independent scroll.
### F2 — New `IncidentHeader.tsx`
```
frontend/src/components/assistant/IncidentHeader.tsx
```
Props: `triageMeta: TriageMeta`, `psaTicketId: string | null`, `sessionId: string`, `onFieldSave(field, value)`, `onResolve()`, `onOverflow()`
- Single-row bar with micro-labels (CLIENT / DEVICE / CATEGORY / HYPOTHESIS)
- Each field: `✏` icon visible on hover → opens inline `EditPopover` (text input + Save/Cancel)
- On Save: calls `aiSessionsApi.updateTriage(sessionId, { [field]: value })`
- Empty fields: muted placeholder ("Unknown client", "No device specified", etc.)
- Right side: PSA ticket badge (if linked) + Resolve button + `⋯` overflow menu
### F3 — Refactored `StepsPanel.tsx` (from `TaskLane`)
```
frontend/src/components/assistant/StepsPanel.tsx
```
Preserves all `TaskLane` data logic and persistence. Changes rendering only:
| State | Icon | Style |
|-------|------|-------|
| Completed | `✓` | Strikethrough, muted, green icon |
| Active | `→` | Blue left border, white text, full opacity |
| Pending | `○` | Muted text |
Script generation CTA: shown at bottom when active step `command` references "script" or AI has flagged it.
`TaskLane.tsx` can remain for now (no renames required in this phase) — `StepsPanel` is a new component that consumes the same `activeActions` prop.
### F4 — New `FlowPilotAsks.tsx`
```
frontend/src/components/assistant/FlowPilotAsks.tsx
```
Props: `questions: QuestionItem[]`, `onAnswer(answer: string)`
- Renders first unanswered question
- `question.options` non-null → button row; clicking calls `onAnswer(option)`
- `question.options` null → compact text input + Send
- `onAnswer` calls parent's `handleSend` with the answer string
- Hidden entirely when `questions` is empty
### F5 — New `WhatWeKnow.tsx`
```
frontend/src/components/assistant/WhatWeKnow.tsx
```
Props: `items: EvidenceItem[]`, `onAdd(text, status)`, `onEdit(index, text, status)`
- Evidence list: `✓` confirmed (green) / `✗` ruled out (red) / `?` pending (muted)
- "+ Add finding" inline entry at bottom
- Click any item to edit inline
- State lives in `AssistantChatPage` (`triageMeta.evidence_items`), synced to backend via `PATCH /triage`
### F6 — Drag-resizable split
Thin handle bar between work zone and conversation log. On drag: update `workZoneHeight` in state, persist to `localStorage`. On mount: restore, default `55%`.
### F7 — Compact `ConversationLog` rendering
Replace current full `<ChatMessage>` bubbles in the log zone with a compact list: `you: ...` / `fp: ...` prefix style, tighter line height, no avatars. `ChatMessage` can still be used for rich content (forks, suggested flows) in a compact variant. Individual messages should support click-to-expand for full rendering when the engineer needs to re-read a longer response or review a suggested flow.
### F8 — Redesigned `ConcludeSessionModal.tsx`
On open:
1. Call `aiSessionsApi.getHandoffDraft(sessionId)` (streaming) — fields fill in as stream arrives
2. Render: outcome selector (Resolved / Escalated / Parked)
3. Render 4 structured editable fields: Root Cause, Resolution, Steps Taken, Recommendations
4. Render output destination checkboxes: Post to CW note / Save to KB / Send client summary
5. Confirm → call resolve/escalate/pause with enriched request body including structured fields
### F9 — Sidebar visual demotion
The existing `ChatSidebar` stays functionally unchanged but should be visually softened so the cockpit — not the session list — reads as the primary surface. Specific changes:
- Reduce sidebar background contrast (use `bg-sidebar` or one step darker)
- Reduce sidebar header prominence (smaller label, no bold "Chat History" heading)
- Rename "Chat History" → "Case History" (part of language pass)
- Default sidebar to collapsed state on first cockpit load (existing collapse toggle + `localStorage`)
### F10 — MSP-native language pass
| Old | New |
|-----|-----|
| "AI Assistant" (page title, meta) | "FlowPilot" |
| "New Chat" | "New Case" |
| "Messages" | "Conversation Log" |
| "Task Lane" (panel label) | "Steps" |
| "Conclude" | "Close Case" |
| "Chat history" (sidebar label) | "Case History" |
| Compose placeholder | "Describe finding, paste log output, or ask FlowPilot..." |
### F11 — New API methods (`aiSessions.ts`)
```typescript
updateTriage(sessionId: string, fields: Partial<TriageMeta>): Promise<TriageMeta>
getHandoffDraft(sessionId: string): AsyncGenerator<HandoffDraftChunk>
```
### F12 — New types (`types/ai-session.ts`)
```typescript
interface TriageMeta {
client_name: string | null
asset_name: string | null
issue_category: string | null
triage_hypothesis: string | null
evidence_items: EvidenceItem[]
}
interface EvidenceItem {
text: string
status: 'confirmed' | 'ruled_out' | 'pending'
}
interface TriageUpdate extends Partial<TriageMeta> {}
// Extend existing:
interface QuestionItem {
text: string
context: string
options?: string[] // new
}
```
---
## Phased Execution Order
### Phase 1 — Backend Foundation
> Lock backend schema and API changes first so the cockpit can be built against stable session contracts.
1. Write migration `071` — add 5 columns to `ai_sessions`
2. Run `alembic upgrade head`, verify columns
3. Update `AISession` model with new mapped columns
4. Add `TriageUpdate` schema, extend `QuestionItem`, extend `ChatMessageResponse`
5. Extend `ResolveSessionRequest` / `EscalateSessionRequest` with structured fields
6. Add `PATCH /{id}/triage` endpoint
7. Add `POST /{id}/handoff-draft` streaming endpoint
8. Update `GET /ai-sessions/{id}` response to include new triage fields
9. Update `resolution_output_generator._build_session_context()` to use structured fields
10. Run backend tests — `pytest --override-ini="addopts="`
### Phase 2 — Triage Extraction (AI layer)
11. Add `[TRIAGE_UPDATE]` marker to `unified_chat_service.py` system prompt
12. Implement `_parse_triage_update_marker()` in the service (follow existing `_parse_questions_marker` / `_parse_actions_marker` pattern)
13. Auto-PATCH session on non-null `triage_update` (respect manual-edit authority: skip fields in `triage_manual_fields`)
14. Add `options` generation instructions to `[QUESTIONS]` system prompt section
15. **GATE:** Verify extraction in 5 real sessions. If `[TRIAGE_UPDATE]` is emitted reliably (≥4/5), proceed. Otherwise switch to Haiku post-response fallback before wiring into the header.
### Phase 3 — New Frontend Types + API
16. Add `TriageMeta`, `EvidenceItem`, `TriageUpdate` to `types/ai-session.ts`
17. Extend `QuestionItem` type
18. Add `updateTriage()` and `getHandoffDraft()` to `aiSessions.ts`
### Phase 4 — New Work Zone Components
19. Extract `TaskLane` persistence logic into `useTaskPersistence` hook (sessionStorage drafts, debounced saves, restoration) — prerequisite for StepsPanel
20. Build `IncidentHeader.tsx` with `EditPopover`
21. Build `StepsPanel.tsx` (presentation only — receives props from hook)
22. Build `FlowPilotAsks.tsx`
23. Build `WhatWeKnow.tsx`
### Phase 5 — Page Layout Refactor
24. Refactor `AssistantChatPage.tsx` — implement stacked cockpit layout
25. Wire `triageMeta` state, session load population, `triage_update` merge (with `triage_manual_fields` guard)
26. Implement drag-resizable split with `localStorage` persistence
27. Compact `ConversationLog` rendering (with click-to-expand for long messages)
### Phase 6 — Handoff Modal + Language Pass + Sidebar
28. Redesign `ConcludeSessionModal.tsx` — structured handoff form (calls `/handoff-draft` for preview, confirms via existing resolve/escalate endpoints which trigger `ResolutionOutputGenerator`)
29. Sidebar visual demotion — background, label prominence, default-collapsed
30. MSP-native language pass across all assistant components
31. Update `<PageMeta>` title
### Phase 7 — QA + Hardening
32. `npx tsc -b` — fix any TypeScript errors
33. `npm run build` — production build clean
34. Functional regression: all chat flows, session switching, conclude/resume
35. Harness feel test: cockpit within 3 seconds?
36. Mobile viewport check
37. Stress test: 50+ messages, 10+ steps, long outputs
---
## Risks and Mitigations
| Risk | Mitigation |
|------|-----------|
| `[TRIAGE_UPDATE]` marker extraction is unreliable — AI doesn't emit it consistently | Gate Phase 2 on a pass/fail test with 5 real sessions before wiring it to the header. Fall back to Option B (post-response Haiku pass) if needed. |
| Header fields feel fabricated — AI guesses wrong client or hypothesis | Show confidence-aware placeholder copy ("FlowPilot is building context…") until a field has real data. Never invent. |
| Task lane visual promotion breaks established chat patterns | Keep all send/respond behavior intact. Change hierarchy only. Verify every task-lane state transition manually. |
| Handoff modal exposes weak underlying summaries | Reuse existing `ResolutionOutputGenerator` output where possible. Add guardrail copy for empty fields. |
| Mobile loses compose or step access | Test responsive layout as a first-class deliverable in Phase 7, not a final sweep. Enforce scroll isolation between all zones. |
| `tsc -b` errors after component refactor | Run `npx tsc -b` after every phase. Trace unused imports/props immediately — don't batch (lesson #92). |
---
## Test Plan
### Harness Feel (primary, subjective)
- Does the page read as an MSP triage cockpit within 3 seconds on first load?
- Is the active step obvious without reading chat?
- Do FlowPilot Asks quick-reply buttons work and update the step list?
- Does the incident header update mid-session as AI learns context?
- Drag handle, refresh — does split restore?
- Does the conclude modal look like a case handoff or a chat closure?
### Functional Regression
- New session (no PSA) — header degrades gracefully
- New session (with CW ticket) — header populates from ticket data
- Send message → `triage_update` updates header
- Click quick-reply button → answer submitted, step advances
- Add finding to What We Know → persisted via PATCH
- Edit header field via `✏` → saved and survives refresh
- Conclude as Resolved → handoff draft fills modal → post to CW note
- Conclude as Escalated → same
- Pause and resume → triage header restores from saved session fields
- Session switching (currentChatRef guard) — no stale state
- Image paste, forks, suggested flows — all still work
### MSP Scenarios (from docx)
1. Single-user endpoint issue (basic triage flow, script generation)
2. M365 / tenant-wide issue (multi-user context, issue category)
3. Network / VPN outage (asset targeting, hypothesis tracking)
4. Escalation and resume (session persistence, structured handoff)
### Edge Cases
- 50+ messages — layout hierarchy stays intact
- 10+ steps — step panel scrolls, compose remains accessible
- Long issue titles / hypothesis text — header truncates gracefully
- Missing PSA context — placeholder copy, not blank fields
- Narrow mobile viewport — all zones reachable
### Backend Checks
```bash
# Migration
alembic upgrade head
psql -U postgres -d resolutionflow -c "\d ai_sessions" | grep -E "client_name|asset_name|issue_category|triage_hypothesis|evidence_items"
# Triage PATCH
curl -X PATCH http://localhost:8000/ai-sessions/{id}/triage \
-H "Authorization: Bearer $TOKEN" \
-d '{"client_name":"Test Client","triage_hypothesis":"Cache corruption"}'
# Handoff draft stream
curl -X POST http://localhost:8000/ai-sessions/{id}/handoff-draft \
-H "Authorization: Bearer $TOKEN"
```
---
## Assumptions
- Desktop is the primary target; mobile must remain usable but does not drive the layout.
- `/assistant` remains the chat-session cockpit; `/pilot` is out of scope.
- New triage fields are **additive** — they do not replace `problem_summary`, `problem_domain`, `ticket_data`, or `conversation_messages`.
- `issue_category` is the operator-facing display field; `problem_domain` remains the internal classifier. Both coexist.
- `evidence_items` is editable by both AI and engineer; engineer edits persist through the triage PATCH endpoint.
- PSA context is optional — every triage header field must degrade gracefully when PSA is absent or session is free-text-only.
- The existing `TaskLane.tsx` component remains in the codebase — `StepsPanel` is a new component that consumes the same props with different rendering. No risky renames during this work.
---
## Critical Files
| File | Change |
|------|--------|
| `backend/alembic/versions/071_add_triage_fields_to_ai_sessions.py` | New migration |
| `backend/app/models/ai_session.py` | Add 5 new mapped columns |
| `backend/app/schemas/ai_session.py` | `TriageUpdate`, `QuestionItem.options`, extended request/response schemas |
| `backend/app/api/endpoints/ai_sessions.py` | `PATCH /triage`, `POST /handoff-draft` |
| `backend/app/services/unified_chat_service.py` | `[TRIAGE_UPDATE]` marker extraction, auto-PATCH |
| `backend/app/services/resolution_output_generator.py` | Structured fields in context builder |
| `frontend/src/types/ai-session.ts` | `TriageMeta`, `EvidenceItem`, `TriageUpdate`; extend `QuestionItem` |
| `frontend/src/api/aiSessions.ts` | `updateTriage()`, `getHandoffDraft()` |
| `frontend/src/pages/AssistantChatPage.tsx` | Full cockpit layout refactor |
| `frontend/src/components/assistant/IncidentHeader.tsx` | New |
| `frontend/src/components/assistant/StepsPanel.tsx` | New (from TaskLane logic) |
| `frontend/src/components/assistant/FlowPilotAsks.tsx` | New |
| `frontend/src/components/assistant/WhatWeKnow.tsx` | New |
| `frontend/src/components/assistant/ConcludeSessionModal.tsx` | Redesigned |

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,655 @@
# Tenant Data Isolation — Design Spec
> **Date:** 2026-04-09
> **Status:** Approved — ready for implementation planning
> **Approach:** PostgreSQL RLS as safety net; application-layer filtering as primary enforcement
---
## Overview
ResolutionFlow is a multi-tenant SaaS. The tenant boundary is `account_id` (UUID foreign key to `accounts.id`). Two accounts must be completely isolated at every layer: one client must never be able to see, access, query, or receive data belonging to another client — even if there is a bug in application code.
This spec establishes the complete foundation that must be in place before further feature development proceeds.
### Design Principle
**Application code is the primary enforcement mechanism. PostgreSQL Row-Level Security (RLS) is the safety net.**
Both layers must always be present. Developers must never rely on RLS to do filtering that application code should be doing. A missing `tenant_filter()` in application code is a code review failure even if RLS would have caught it.
---
## Current State
- Tenant boundary: `account_id` (not `team_id` — that column is legacy)
- Isolation today: 100% application-layer, manual per-endpoint `.where(account_id == ...)`
- No RLS, no DB session variables, no middleware tenant injection
- Nullable `account_id` on ~8 core models (User, Tree, TreeCategory, etc.)
- ~20 tables with no direct `account_id` (scoped only through join chains)
- 4 models with `team_id` only, no `account_id`: TargetList, ScriptBuilderSession, ScriptTemplate, ScriptGeneration
- Known existing gaps: see Section 4
---
## Section 1: Schema Changes
### 1a. Denormalize `account_id` onto all tenant-relevant tables
Add `account_id UUID NOT NULL` (with FK to `accounts.id` and index) to each of the following tables that currently lack it:
| Table | Backfill path |
|---|---|
| `sessions` | `sessions.user_id → users.account_id` |
| `attachments` | `attachments.session_id → sessions.user_id → users.account_id` |
| `session_supporting_data` | `session_supporting_data.session_id → sessions.user_id → users.account_id` |
| `audit_logs` | `audit_logs.user_id → users.account_id` |
| `maintenance_schedules` | `maintenance_schedules.tree_id → trees.account_id` |
| `user_folders` | `user_folders.user_id → users.account_id` |
| `user_pinned_trees` | `user_pinned_trees.user_id → users.account_id` |
| `session_branches` | `session_branches.ai_session_id → ai_sessions.account_id` (verify column name — may be `session_id`) |
| `session_handoffs` | `session_handoffs.ai_session_id → ai_sessions.account_id` (verify column name — may be `session_id`) |
| `session_resolution_outputs` | `session_resolution_outputs.session_id → sessions.user_id → users.account_id` |
| `fork_points` | `fork_points.session_id → ai_sessions.account_id` |
| `ai_session_steps` | `ai_session_steps.session_id → ai_sessions.account_id` |
| `ai_suggestions` | `ai_suggestions.tree_id → trees.account_id` |
| `step_ratings` | `step_ratings.user_id → users.account_id` (backfill from rater, not the step) |
| `step_usage_logs` | `step_usage_logs.user_id → users.account_id` (backfill from user, not the step) |
| `psa_post_logs` | `psa_post_logs.psa_connection_id → psa_connections.account_id` (psa_connections already has account_id NOT NULL) |
| `psa_member_mappings` | `psa_member_mappings.psa_connection_id → psa_connections.account_id` |
| `notification_logs` | `notification_logs.notification_config_id → notification_configs.account_id` |
**Deferred:** `tree_shares` — backfill strategy and RLS policy depend on whether sharing is intra-tenant only or cross-tenant. Must be resolved before RLS is enabled on this table. See Section 7 (Open Questions).
### 1b. Make existing nullable `account_id` columns NOT NULL
These tables have `account_id` already but it is nullable:
- `users`
- `trees`
- `tree_categories`
- `tree_tags`
- `step_categories`
- `step_library`
- `tree_embeddings`
- `feedback`
Action: Backfill any NULL rows (assign to correct account or delete if orphaned test/seed data), then `ALTER COLUMN account_id SET NOT NULL`.
### 1c. Global / template content
Move global content out of tenant tables into dedicated tables with no RLS:
- **`template_trees`** — default and publicly visible trees (currently `is_default=True` or `visibility='public'` in `trees`). All tenants can read; no RLS.
- **`platform_steps`** — public steps from step library (currently `visibility='public'` in `step_library`). All tenants can read; no RLS.
- Tables already global (no change needed): `script_categories`, `platform_settings`, `plan_limits`, `feature_flags`, `plan_feature_defaults`.
### 1d. Migration sequence (per table)
Every table that gains `account_id` follows this exact sequence. Each step must succeed before the next begins:
1. `ADD COLUMN account_id UUID` (nullable)
2. Backfill via `UPDATE ... JOIN ...`
3. `SELECT COUNT(*) WHERE account_id IS NULL` — must be zero before proceeding
4. `ALTER COLUMN account_id SET NOT NULL`
5. `CREATE INDEX ON <table>(account_id)`
6. Enable RLS (in Phase 3, not here)
Any migration that cannot reach step 3 (zero NULLs) must roll back completely. No partial state.
---
## Section 2: PostgreSQL Roles & RLS Infrastructure
### 2a. Database roles
Two PostgreSQL roles:
**`resolutionflow_app`**
- Used by all application requests
- Subject to all RLS policies
- Standard table privileges: `SELECT, INSERT, UPDATE, DELETE` on all tenant tables
**`resolutionflow_admin`**
- Used by: Alembic migrations, seed scripts, super admin API endpoints, scheduled background jobs requiring cross-tenant access
- Has `BYPASSRLS` attribute — not subject to RLS policies
- Same table privileges as `resolutionflow_app`
- Connection string exposed as `DATABASE_ADMIN_URL` env var
The current `postgres` superuser is replaced in the application by these two roles. The connection string in `DATABASE_URL` transitions to `resolutionflow_app`. Alembic uses `DATABASE_URL_SYNC` pointing to `resolutionflow_admin`.
### 2b. Per-request tenant context injection
Every request that passes through `get_db()` must execute, inside a transaction boundary:
```sql
SET LOCAL app.current_account_id = '<account_uuid>';
```
`SET LOCAL` is transaction-scoped — it resets automatically when the transaction ends. No cleanup needed. This is implemented in a modified `get_db()` dependency that receives `current_user` and executes the SET before yielding the session.
**Fail-closed behavior:** If `app.current_account_id` is not set (e.g., a bug where `SET LOCAL` was skipped), `current_setting('app.current_account_id', false)::uuid` returns NULL. `NULL = NULL` is false in SQL — the RLS policy matches zero rows. This is the correct fail-closed behavior.
Public endpoints (no authenticated user) do not call `SET LOCAL`. RLS matches zero rows for all tenant tables. This is correct.
### 2c. RLS policy pattern
Every tenant table (from Section 1 + all existing tables with account_id) gets:
```sql
ALTER TABLE <table> ENABLE ROW LEVEL SECURITY;
ALTER TABLE <table> FORCE ROW LEVEL SECURITY;
-- SELECT
CREATE POLICY tenant_select ON <table> FOR SELECT
USING (account_id = current_setting('app.current_account_id', false)::uuid);
-- INSERT
CREATE POLICY tenant_insert ON <table> FOR INSERT
WITH CHECK (account_id = current_setting('app.current_account_id', false)::uuid);
-- UPDATE
CREATE POLICY tenant_update ON <table> FOR UPDATE
USING (account_id = current_setting('app.current_account_id', false)::uuid)
WITH CHECK (account_id = current_setting('app.current_account_id', false)::uuid);
-- DELETE
CREATE POLICY tenant_delete ON <table> FOR DELETE
USING (account_id = current_setting('app.current_account_id', false)::uuid);
```
**`FORCE ROW LEVEL SECURITY`** ensures the table owner is also subject to policies. The `resolutionflow_admin` role bypasses via its `BYPASSRLS` attribute, not via ownership.
**`audit_logs` exception:** SELECT policy only. No `WITH CHECK` on INSERT (app inserts audit logs freely). No UPDATE or DELETE policies ever. These constraints are permanent and must be documented in the migration comment.
**Global tables** (`platform_settings`, `plan_limits`, `feature_flags`, `plan_feature_defaults`, `template_trees`, `platform_steps`): No RLS.
### 2d. Connection pool reuse safety
The `SET LOCAL` approach is transaction-scoped. A connection pool reuse test (see Section 6) must verify that a connection returned to the pool after tenant A's request does not carry tenant A's `account_id` into tenant B's request. This is guaranteed by `SET LOCAL` (resets on transaction end) but must be explicitly verified.
---
## Section 3: Application-Layer Enforcement Patterns
### 3a. `tenant_filter()` helper
Add to `backend/app/core/filters.py`:
```python
def tenant_filter(model, account_id: uuid.UUID):
"""
Primary app-layer tenant filter.
MUST be used in every SELECT/UPDATE/DELETE on tenant tables.
RLS is the safety net — this is the primary enforcement.
"""
return model.account_id == account_id
```
All existing filter helpers (`build_tree_access_filter`, `build_step_visibility_filter`) must internally call `tenant_filter()` as their base constraint.
### 3b. Fetch-and-verify pattern
For ID-based lookups, filter by **both** `id` AND `account_id` in the query — not fetch-then-check:
```python
# Correct
stmt = select(Tree).where(
Tree.id == tree_id,
tenant_filter(Tree, current_user.account_id)
)
tree = (await db.execute(stmt)).scalar_one_or_none()
if not tree:
raise HTTPException(status_code=404)
# Prohibited — fetch first, then check
tree = await db.get(Tree, tree_id)
if tree.account_id != current_user.account_id:
raise HTTPException(status_code=403) # Reveals existence — also wrong
```
Endpoints must return **404, not 403**, for cross-tenant ID lookups. Never confirm that a resource exists.
### 3c. `get_tenant_context` dependency
Add to `backend/app/api/deps.py`:
```python
async def get_tenant_context(
current_user: User = Depends(get_current_active_user)
) -> uuid.UUID:
"""
Returns the current user's account_id.
Raises 403 if the user has no account association.
Inject this instead of accessing current_user.account_id directly.
"""
if current_user.account_id is None:
raise HTTPException(status_code=403, detail="User not associated with any account")
return current_user.account_id
```
### 3d. Insert pattern
All inserts on tenant tables must explicitly set `account_id`. RLS `WITH CHECK` rejects inserts where `account_id` doesn't match the session variable, but application code must also set it:
```python
new_record = Tree(
account_id=tenant_account_id, # Required — never omit
...
)
```
### 3e. Code review checklist rule
Every PR that touches a tenant table must be verified against:
1. Every SELECT/UPDATE/DELETE includes `tenant_filter()` or a wrapper that calls it
2. ID lookups filter by both `id` and `account_id` in the same query
3. Inserts explicitly set `account_id`
4. 404 (not 403) returned for cross-tenant ID lookups
5. A cross-tenant isolation test is included (Phase 2 onwards)
### 3f. CI grep check
A grep-based CI check is active from the end of Phase 0 on all PRs. It flags:
- Queries on known tenant tables that don't include `tenant_filter` or `account_id` as a filter term
- Initial implementation: warn (not block) to allow calibration; switch to block after 2 weeks of false-positive tuning
Pattern to be defined during Phase 0 implementation.
---
## Section 4: Existing Gap Fixes
### Immediate Hotfix (ships before Phase 0)
**CRITICAL: Copilot tree access bypass**
- **Files:** `backend/app/api/endpoints/copilot.py`, `backend/app/services/copilot_service.py`
- **Issue:** `start_conversation()` loads a tree by UUID without verifying the requesting user has access. An attacker who knows a tree UUID from another account can extract its full structure, node names, and descriptions via the AI system prompt.
- **Fix:** Add `can_access_tree(current_user, tree)` check in the endpoint after loading the tree. Also add `tenant_filter(Tree, account_id)` to the tree query in `copilot_service.py`. Raise 404 (not 403) if the tree is not found or not accessible.
- Ships as an independent hotfix PR, merged immediately.
### Phase 0 Fixes
**LOW: Analytics flow endpoint** (`backend/app/api/endpoints/analytics.py`)
- `GET /analytics/flows/{tree_id}` returns analytics for any tree by UUID with no access check
- Fix: Add `tenant_filter(Tree, current_user.account_id)` to the tree fetch query. 404 if not found.
**LOW: Category tree count** (`backend/app/api/endpoints/categories.py`)
- Tree count per category includes trees from all accounts
- Fix: Add `tenant_filter(Tree, current_user.account_id)` to the count subquery
**LOW: AI session scope inconsistency** (`backend/app/api/endpoints/ai_sessions.py`)
- List endpoint is user-scoped (`user_id == current_user.id`); search endpoint uses `OR(user_id, account_id)` exposing other users' session summaries within the same account
- Sessions are user-scoped only. Cross-user access permitted only via explicit escalation or session sharing
- Fix: Restrict search endpoint to `user_id == current_user.id`. List and search must behave consistently.
**Phase 0 UUID endpoint audit**
- Systematically review every endpoint with a `{resource_id}` URL parameter
- For each: verify that the ID lookup either (a) filters by `id AND account_id` in the query, or (b) calls `can_access_<resource>(current_user, resource)` on the fetched object
- Document every instance found, classify by severity, fix all before Phase 0 closes
---
## Section 5: Legacy `team_id` Migration
### 5a. TargetList — audit-gated
Before any migration work on `TargetList`:
1. Run a full codebase reference audit: grep for all references to `TargetList`, `target_list`, `target-list`
2. Query the production and staging databases for row count
3. Decision tree:
- Zero code references AND zero rows → drop the table entirely
- Zero rows but code references exist → deprecate code, then drop
- Rows exist → migrate (see sequence below)
4. Migration only proceeds after audit result is documented and approved
If migration proceeds:
- Backfill path: `team_id → teams → users WHERE is_team_admin → account_id`
- If any row cannot be backfilled to a valid `account_id`**full rollback**, no exceptions, no manual review queues
### 5b. Pre-migration: teams-to-accounts orphan check
Before any backfill using `team_id → account_id` chains, run:
```sql
SELECT COUNT(*) FROM teams t
LEFT JOIN users u ON u.team_id = t.id AND u.account_id IS NOT NULL
WHERE u.id IS NULL;
```
Count must be zero before any backfill proceeds. Report and resolve orphaned teams first.
### 5c. Approved migrations (no audit gate)
**ScriptBuilderSession:** Add `account_id`, backfill from `user_id → users.account_id`, set NOT NULL.
**ScriptTemplate:** Add `account_id`, backfill from `created_by_id → users.account_id`, set NOT NULL.
**ScriptGeneration:** Add `account_id`, backfill from `user_id → users.account_id`, set NOT NULL.
### 5d. team_id cleanup
Do NOT drop `team_id` columns during migration. Keep until all application code is updated to use `account_id` exclusively. Drop `team_id` columns in a later cleanup migration after verification.
---
## Section 6: Testing Strategy
### Phase 1: RLS validation tests
**File:** `backend/tests/test_rls_isolation.py`
This test suite validates RLS policies at the database layer, independent of any application code. It connects to the test database using the `resolutionflow_app` role.
**Setup fixture:**
- Creates two accounts (`account_a`, `account_b`) with seed rows in all tenant tables
- Creates an async DB connection using `resolutionflow_app` role
- Sets `SET LOCAL app.current_account_id = '<account_a_uuid>'` before each test
**Test cases per table (~5 cases per table, ~32 tables ≈ ~160 total):**
1. **SELECT isolation** — querying as account_a returns zero rows for account_b's data
2. **INSERT enforcement** — inserting with `account_id = account_b_uuid` raises a PostgreSQL exception (rejected by `WITH CHECK`)
3. **INSERT cross-tenant FK** — inserting with correct `account_id` but a FK value (e.g., `tree_id`) that belongs to account_b is also rejected
4. **UPDATE enforcement** — updating account_b's rows as account_a affects zero rows
5. **DELETE enforcement** — deleting account_b's rows as account_a affects zero rows
**Fail-closed test:**
```python
# Unset app.current_account_id — must raise a database exception
# Acceptable: psycopg2.errors.InvalidTextRepresentation (NULL::uuid cast fails)
# NOT acceptable: query returning zero rows silently
with pytest.raises(asyncpg.PostgresError):
await conn.execute("SELECT * FROM trees")
```
The exact exception type must be documented based on the behavior of `current_setting('app.current_account_id', false)::uuid` when unset. The test asserts on that specific exception.
**audit_logs special cases:**
- No INSERT `WITH CHECK` test — audit logs must be insertable freely
- No UPDATE or DELETE tests — those policies must not exist
- Verify via `pg_policies` that only a SELECT policy exists on `audit_logs`
**tree_shares:** Intentionally deferred. A TODO comment is included:
```python
# TODO: tree_shares RLS tests deferred pending sharing model decision.
# Must be added before RLS is enabled on the tree_shares table.
# See: docs/superpowers/specs/2026-04-09-tenant-data-isolation-design.md Section 7
```
**Tables covered in Phase 1:**
`trees`, `ai_sessions`, `ai_chat_sessions`, `ai_conversations`, `sessions`, `step_library`, `tree_categories`, `tree_tags`, `step_categories`, `flow_proposals`, `attachments`, `audit_logs`, `psa_connections`, `copilot_conversations`, `file_uploads`, `kb_imports`, `subscriptions`, `account_invites`, `ai_usage`, `notifications`, `session_shares`, `script_builder_sessions`, `script_templates`, `script_generations`, `maintenance_schedules`, `user_folders`, `user_pinned_trees`, `session_supporting_data`, `session_branches`, `session_resolution_outputs`, `psa_post_logs`, `notification_logs`
**Connection pool reuse test:**
```python
async def test_set_local_does_not_leak_between_connections():
"""SET LOCAL must not leak account_id when a connection is returned to the pool."""
conn = await pool.acquire()
async with conn.transaction():
await conn.execute("SET LOCAL app.current_account_id = $1", str(account_a_id))
# transaction ends, SET LOCAL resets
# Return conn to pool, re-acquire — verify account_id is not set
conn2 = await pool.acquire()
result = await conn2.fetchval("SELECT current_setting('app.current_account_id', true)")
assert result is None or result == ""
```
### Phase 2: Per-endpoint cross-tenant tests
As each endpoint is touched in any PR from Phase 1 onward, a cross-tenant isolation test is added in the same PR:
```python
async def test_cannot_access_other_account_<resource>(
client_account_a, account_b_resource
):
"""Account A cannot access Account B's resource by UUID."""
response = await client_account_a.get(f"/<resource>/{account_b_resource.id}")
assert response.status_code == 404 # Not 403 — never reveal existence
```
This is a hard requirement per the code review checklist.
---
## Section 7: Phased Rollout Plan
### Immediate Hotfix (before everything else)
- Fix CRITICAL: Copilot tree access bypass (see Section 4)
- Ships as independent PR, merged immediately, does not wait for Phase 0
---
### Phase 0 — Foundation
Goal: Fix all existing gaps; establish patterns and tooling; gate future PRs.
1. Fix LOW: Analytics flow endpoint missing ownership check
2. Fix LOW: Category tree count scope
3. Fix LOW: AI session search/list inconsistency (restrict both to `user_id`)
4. **Full UUID endpoint audit** — every `{resource_id}` URL param checked. All gaps documented and fixed before Phase 0 closes.
5. Add `get_tenant_context` dependency to `deps.py`
6. Add `tenant_filter()` helper to `filters.py`. Update existing filter helpers to call it.
7. **Dead code audit:** TargetList references + database row count. Report result.
8. **Orphan check:** Teams without a resolvable account_id. Report result.
9. **Define and activate CI grep check** for missing `tenant_filter()` on tenant tables. Active on all PRs from Phase 1 forward.
Gate: All gaps patched. CI grep check active. TargetList and team orphan audit results documented.
---
### Phase 1 — Schema Migration
Goal: Every tenant-relevant table has a direct, NOT NULL `account_id` column.
10. Add `account_id` to all tables in Section 1a (migration per logical domain group: core sessions, PSA, AI, steps, notifications)
11. Make nullable `account_id` NOT NULL on models from Section 1b
12. Migrate `ScriptBuilderSession`, `ScriptTemplate`, `ScriptGeneration` from `team_id` to `account_id`
13. `TargetList`: execute result of Phase 0 audit (drop or migrate)
14. Create `template_trees` and `platform_steps` tables; migrate global content
Gate: Zero NULL `account_id` values in any tenant table. All backfills verified. Database passes zero-NULL assertion query for every table in scope.
---
### Phase 2 — PostgreSQL Infrastructure
Goal: Database roles established; `SET LOCAL` wired into every request; RLS test suite green.
15. Create `resolutionflow_app` and `resolutionflow_admin` PostgreSQL roles; grant privileges
16. Update `DATABASE_URL` to `resolutionflow_app`; add `DATABASE_ADMIN_URL` for admin connections
17. Update Alembic to use `DATABASE_ADMIN_URL`
18. Modify `get_db()` to execute `SET LOCAL app.current_account_id` per request, inside transaction boundary
19. Update super admin endpoints to use `resolutionflow_admin` connection
20. Write `test_rls_isolation.py` — all ~160 RLS tests. Must be 100% green before Phase 3. Connection pool reuse test included.
21. Measure `SET LOCAL` per-request overhead baseline
Gate: All RLS tests green. All existing integration tests green. Connection pool reuse test green. Performance baseline documented.
---
### Phase 3 — Enable RLS
Goal: RLS policies active on all tenant tables. Phased by domain, not all at once.
Enable RLS in these batches. Run full test suite after each batch before proceeding:
**Batch A: Core data**
`trees`, `tree_categories`, `tree_tags`, `sessions`, `attachments`, `session_supporting_data`, `maintenance_schedules`
**Batch B: AI & sessions**
`ai_sessions`, `ai_chat_sessions`, `ai_conversations`, `ai_usage`, `ai_session_steps`, `session_branches`, `session_handoffs`, `session_resolution_outputs`, `fork_points`, `copilot_conversations`, `flow_proposals`
**Batch C: Steps & library**
`step_library`, `step_categories`, `step_ratings`, `step_usage_logs`, `ai_suggestions`, `template_trees` (no RLS), `platform_steps` (no RLS)
**Batch D: Integrations & users**
`psa_connections`, `psa_post_logs`, `psa_member_mappings`, `subscriptions`, `account_invites`, `file_uploads`, `kb_imports`, `notifications`, `notification_logs`, `session_shares`, `user_folders`, `user_pinned_trees`
**Batch E: Scripts & auth**
`script_builder_sessions`, `script_templates`, `script_generations`, `audit_logs`
For each batch migration: `ENABLE ROW LEVEL SECURITY` + `FORCE ROW LEVEL SECURITY` + create all applicable policies.
Gate: All RLS tests green after each batch. All integration tests green. Staging smoke test after Batch E. No performance regressions.
---
### Phase 4 — Ongoing
- Every PR that touches a tenant endpoint includes a cross-tenant isolation test
- CI grep check blocks PRs with missing `tenant_filter()` (warn → block after 2-week calibration)
- `tree_shares` RLS: design sharing model, implement tests, enable RLS before any sharing feature ships
- `team_id` columns: drop in a cleanup migration after all application code is fully migrated
---
## Section 8: Background Job Policy
Background jobs and scheduled tasks that process tenant data are a distinct isolation surface. Unlike request-scoped endpoints, they do not have a `current_user` and must manage tenant context explicitly.
### Policy
Every background job that touches tenant tables must comply with one of two patterns:
**Pattern A — `resolutionflow_admin` with explicit per-query `account_id` filtering**
Use when: the job is inherently cross-tenant (e.g., processes all pending work across all accounts in a single pass). The admin role bypasses RLS, so explicit `account_id` filters in every query are mandatory — RLS is not the safety net here.
```python
# Allowed: cross-tenant batch SELECT for IDs only, then loop per-account
result = await db.execute(
select(Model.id, Model.account_id).where(Model.status == "pending")
)
for row in result.all():
await _process_one(row.id, row.account_id, db) # account_id threaded through
# In the processing function: all queries must filter by account_id
async def _process_one(record_id, account_id, db):
result = await db.execute(
select(Model).where(Model.id == record_id, Model.account_id == account_id)
)
```
**Pattern B — `resolutionflow_app` with `SET LOCAL` per tenant loop iteration**
Use when: the job processes tenants one at a time and it is practical to set the tenant context per iteration.
```python
for account_id in account_ids:
async with async_session_maker() as db:
await db.execute(
text("SET LOCAL app.current_account_id = :id"),
{"id": str(account_id)}
)
# All queries in this block are RLS-enforced to this tenant
```
### No cross-tenant queries without justification
No background job may issue a SELECT, UPDATE, or DELETE that spans multiple tenants' data in a single query without explicit written justification in the code comment and in this spec. Approved cross-tenant operations are documented below.
### Inventory: Current Background Jobs
| Job | File | Pattern | Status | Notes |
|---|---|---|---|---|
| Knowledge Flywheel | `knowledge_flywheel_scheduler.py` | Admin + explicit filter | **Needs update** | Batch SELECT across all accounts with no `account_id` filter. Must thread `account_id` from the session into all processing calls. |
| PSA Retry | `psa_retry_scheduler.py` | Admin + explicit filter | **Needs update** | Batch SELECT across all accounts with no `account_id` filter. Must add `account_id` to batch query and thread through to `retry_failed_push`. |
| Chat Retention Cleanup | `retention_cleanup.py` | Admin + explicit filter | **Correct pattern** | Already loops per-account with explicit `account_id` in all queries. Model for other jobs. Needs role update to `resolutionflow_admin`. |
| Maintenance Schedule Firing | `scheduler.py` (`_fire_maintenance_schedule`) | Admin + explicit filter | **Needs update** | Fetches `MaintenanceSchedule` and `Tree` by ID without `account_id` filter. After Phase 1, `Session` creation must set `account_id`. Add `account_id` to all queries. |
| AI Conversation Expiry | `scheduler.py` (`_cleanup_expired_ai_conversations`) | Admin, cross-tenant approved | **Correct pattern** | Cross-tenant DELETE by `expires_at` is explicitly justified: rows are expired regardless of tenant. Needs role update to `resolutionflow_admin`. Document this approval in code comment. |
### Approved cross-tenant queries
The following cross-tenant operations are explicitly approved. All others require a new entry here before shipping:
| Job | Query | Justification |
|---|---|---|
| AI Conversation Expiry | `DELETE FROM ai_conversations WHERE expires_at < NOW()` | Time-based expiry is tenant-independent. Deleting expired rows does not expose data across tenants. |
| Chat Retention Cleanup | `SELECT id FROM accounts` | Required to iterate per-account. Read of account IDs only; no tenant data accessed. |
| Knowledge Flywheel (after fix) | `SELECT id, account_id FROM ai_sessions WHERE analysis_status='pending'` | Cross-tenant ID harvest only. No tenant data in the SELECT. `account_id` is threaded into per-tenant processing. |
| PSA Retry (after fix) | `SELECT id, account_id FROM psa_post_logs WHERE status='pending_retry'` | Cross-tenant ID harvest only. `account_id` threaded into per-tenant processing. |
### Checklist additions (Phase 2)
- [ ] All background jobs updated to use `resolutionflow_admin` role via `DATABASE_ADMIN_URL`
- [ ] Knowledge Flywheel: `account_id` added to batch query and threaded through to `analyze_session`
- [ ] PSA Retry: `account_id` added to batch query and threaded through to `retry_failed_push`
- [ ] Maintenance Schedule Firing: all queries include `account_id`; `Session` creation sets `account_id` after Phase 1 migration
- [ ] AI Conversation Expiry: cross-tenant approval comment added to code
- [ ] All jobs reviewed against this policy before Phase 3 (RLS enable)
---
## Section 9: Open Questions
| Question | Impact | Owner |
|---|---|---|
| Is tree sharing intra-tenant only, or can trees be shared across accounts? | Determines `tree_shares` schema, backfill strategy, and RLS policy. Tree_shares table deferred until resolved. | Product |
| What is the exact PostgreSQL exception raised when `current_setting('app.current_account_id', false)::uuid` is evaluated with no value set? | Determines the fail-closed test assertion. Must be tested in Phase 2. | Engineering |
| TargetList audit complete: active code references found in 12+ files across backend and frontend (full CRUD API, frontend page, used in MaintenanceScheduleSection and BatchLaunchModal). Cannot be dropped. Row count confirmed: **0 rows** in production. Decision: migrate to account_id in Phase 1 via backfill from team_id → accounts. Zero rows means backfill is trivial (no data to migrate, just schema change). | ✓ Resolved — migrate in Phase 1. Zero rows confirmed 2026-04-09. | ✓ Done |
| Teams orphan check: **0 orphaned teams** confirmed 2026-04-09. Phase 1 backfill using team_id → account_id chain is safe to proceed. | ✓ Resolved — Phase 1 can proceed without team cleanup. | ✓ Done |
---
## Appendix: Pre-Implementation Checklist
Everything that must be in place before writing feature code on any tenant-data endpoint:
### Foundation (Phase 0)
- [ ] Copilot tree access bypass hotfix shipped
- [ ] Analytics flow endpoint ownership check added
- [ ] Category tree count scoped to account
- [ ] AI session list and search both restricted to `user_id`
- [ ] Full UUID endpoint audit completed; all gaps documented and fixed
- [ ] `get_tenant_context` dependency added to `deps.py`
- [ ] `tenant_filter()` helper added to `filters.py`
- [ ] Existing filter helpers updated to use `tenant_filter()` internally
- [ ] TargetList dead code audit result documented
- [ ] Teams orphan count query run and result documented
- [ ] CI grep check defined and active
### Schema (Phase 1)
- [ ] `account_id NOT NULL` on all tables in Section 1a denormalization list
- [ ] `account_id NOT NULL` on all existing nullable models from Section 1b
- [ ] ScriptBuilderSession, ScriptTemplate, ScriptGeneration migrated from team_id
- [ ] TargetList: dropped or migrated per audit result
- [ ] template_trees and platform_steps tables created
- [ ] Zero NULL assertion passes for every tenant table
- [ ] Migration sequence (add nullable → backfill → verify → NOT NULL → index) followed for each table
### Infrastructure (Phase 2)
- [ ] `resolutionflow_app` role created with correct privileges
- [ ] `resolutionflow_admin` role created with `BYPASSRLS`
- [ ] `DATABASE_URL` updated to `resolutionflow_app`
- [ ] `DATABASE_ADMIN_URL` added for admin connections
- [ ] Alembic uses `DATABASE_ADMIN_URL`
- [ ] `get_db()` executes `SET LOCAL app.current_account_id` per request inside transaction
- [ ] Super admin endpoints use admin connection
- [ ] All background jobs updated to use `resolutionflow_admin` role (see Section 8)
- [ ] Knowledge Flywheel: `account_id` threaded through batch query and `analyze_session`
- [ ] PSA Retry: `account_id` threaded through batch query and `retry_failed_push`
- [ ] Maintenance Schedule: all queries include `account_id`; `Session` creation sets `account_id`
- [ ] AI Conversation Expiry: cross-tenant approval comment added to code
- [ ] `test_rls_isolation.py` written with ~160 test cases
- [ ] All RLS tests pass (100%)
- [ ] Connection pool reuse test passes
- [ ] Fail-closed exception documented and asserted
- [ ] Performance baseline for `SET LOCAL` overhead documented
### RLS Active (Phase 3)
- [ ] Batch A RLS policies applied and all tests green
- [ ] Batch B RLS policies applied and all tests green
- [ ] Batch C RLS policies applied and all tests green
- [ ] Batch D RLS policies applied and all tests green
- [ ] Batch E RLS policies applied and all tests green
- [ ] Staging smoke test passed
- [ ] All existing integration tests green with RLS active
### Ongoing Standards
- [ ] Every new endpoint PR includes cross-tenant isolation test
- [ ] CI grep check blocks PRs with missing `tenant_filter()`
- [ ] `tree_shares` deferred — not enabled until sharing model documented
- [ ] `team_id` cleanup migration scheduled after full migration complete

View File

@@ -7,9 +7,10 @@ interface ChatMessageProps {
role: 'user' | 'assistant'
content: string
suggestedFlows?: SuggestedFlow[]
imageUrls?: string[]
}
export function ChatMessage({ role, content, suggestedFlows }: ChatMessageProps) {
export function ChatMessage({ role, content, suggestedFlows, imageUrls }: ChatMessageProps) {
return (
<div className={`flex gap-3 ${role === 'user' ? 'flex-row-reverse' : ''}`}>
{/* Avatar */}
@@ -17,7 +18,7 @@ export function ChatMessage({ role, content, suggestedFlows }: ChatMessageProps)
className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
role === 'assistant'
? 'bg-primary/15 text-primary'
: 'bg-white/[0.08] text-muted-foreground'
: 'bg-elevated text-muted-foreground'
}`}
>
{role === 'assistant' ? <Sparkles size={14} /> : <User size={14} />}
@@ -25,6 +26,21 @@ export function ChatMessage({ role, content, suggestedFlows }: ChatMessageProps)
{/* Content */}
<div className={`max-w-[80%] space-y-2 ${role === 'user' ? 'text-right' : ''}`}>
{/* Image attachments (user messages only) */}
{role === 'user' && imageUrls && imageUrls.length > 0 && (
<div className={`flex flex-wrap gap-2 ${role === 'user' ? 'justify-end' : ''}`}>
{imageUrls.map((url, i) => (
<a key={i} href={url} target="_blank" rel="noopener noreferrer">
<img
src={url}
alt={`Attachment ${i + 1}`}
className="h-24 w-auto max-w-[200px] rounded-xl object-cover border border-border cursor-pointer hover:opacity-90 transition-opacity"
/>
</a>
))}
</div>
)}
<div
className={`rounded-2xl px-4 py-3 text-[0.875rem] leading-relaxed ${
role === 'user'

View File

@@ -193,7 +193,7 @@ function ChatItem({
className={cn(
'group flex items-center gap-2 px-3 py-2.5 mx-1.5 rounded-lg cursor-pointer transition-colors',
confirming
? 'bg-rose-500/10 border border-rose-500/20'
? 'bg-danger-dim border border-danger/20'
: isActive
? 'bg-accent-dim text-foreground'
: 'text-muted-foreground hover:bg-input hover:text-foreground'
@@ -203,10 +203,10 @@ function ChatItem({
<div className="flex-1 min-w-0">
{confirming ? (
<div className="flex items-center gap-2">
<span className="text-[0.75rem] text-rose-400 font-medium">Delete?</span>
<span className="text-[0.75rem] text-danger font-medium">Delete?</span>
<button
onClick={e => { e.stopPropagation(); onDelete(); setConfirming(false) }}
className="text-[0.6875rem] font-medium text-rose-400 hover:text-rose-300 px-1.5 py-0.5 rounded bg-rose-500/15 hover:bg-rose-500/25 transition-colors"
className="text-[0.6875rem] font-medium text-danger hover:text-danger px-1.5 py-0.5 rounded bg-danger/15 hover:bg-danger/25 transition-colors"
>
Yes
</button>
@@ -230,14 +230,14 @@ function ChatItem({
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={e => { e.stopPropagation(); onTogglePin() }}
className="p-1 rounded hover:bg-white/[0.08]"
className="p-1 rounded hover:bg-elevated"
title={chat.pinned ? 'Unpin' : 'Pin'}
>
<Pin size={12} className={chat.pinned ? 'text-primary' : ''} />
</button>
<button
onClick={e => { e.stopPropagation(); setConfirming(true) }}
className="p-1 rounded hover:bg-white/[0.08] text-muted-foreground hover:text-rose-400"
className="p-1 rounded hover:bg-elevated text-muted-foreground hover:text-danger"
title="Delete"
>
<Trash2 size={12} />

View File

@@ -36,7 +36,7 @@ const OUTCOMES: { value: ConclusionOutcome; label: string; description: string;
label: 'Resolved',
description: 'Issue has been fixed or answered',
icon: CheckCircle2,
color: 'text-emerald-400',
color: 'text-success',
bg: 'bg-emerald-400/10',
border: 'border-emerald-400/30',
},
@@ -45,17 +45,17 @@ const OUTCOMES: { value: ConclusionOutcome; label: string; description: string;
label: 'Escalate',
description: 'Needs to be handed off or escalated',
icon: ArrowUpRight,
color: 'text-amber-400',
bg: 'bg-amber-400/10',
border: 'border-amber-400/30',
color: 'text-warning',
bg: 'bg-warning-dim',
border: 'border-warning/30',
},
{
value: 'paused',
label: 'Paused',
description: 'Continuing later — saving progress',
icon: Pause,
color: 'text-blue-400',
bg: 'bg-blue-400/10',
color: 'text-accent',
bg: 'bg-accent-dim',
border: 'border-blue-400/30',
},
]
@@ -362,7 +362,7 @@ export function ConcludeSessionModal({
</div>
{error && (
<div className="text-sm text-rose-400 bg-rose-400/10 border border-rose-400/20 rounded-lg px-4 py-2">
<div className="text-sm text-danger bg-danger-dim border border-danger/20 rounded-lg px-4 py-2">
{error}
</div>
)}
@@ -410,7 +410,7 @@ export function ConcludeSessionModal({
<div className="h-3 bg-elevated rounded w-4/5" />
</div>
) : streamError ? (
<div className="flex items-center gap-2 text-sm text-amber-400">
<div className="flex items-center gap-2 text-sm text-warning">
<AlertTriangle size={14} />
{streamError}
</div>
@@ -467,7 +467,7 @@ export function ConcludeSessionModal({
{/* Paused/Escalated: generating spinner */}
{(outcome === 'paused' || outcome === 'escalated') && generatingUpdate && (
<div className="flex flex-col items-center justify-center py-8 gap-3">
<Loader2 size={24} className="animate-spin text-blue-400" />
<Loader2 size={24} className="animate-spin text-accent" />
<p className="text-sm text-muted-foreground">Generating status update...</p>
</div>
)}
@@ -544,7 +544,7 @@ export function ConcludeSessionModal({
{outcome === 'paused' && (
<button
onClick={handleResumeNew}
className="flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-blue-400 bg-blue-400/10 border border-blue-400/20 hover:bg-blue-400/15 transition-all"
className="flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-accent bg-accent-dim border border-accent/20 hover:bg-accent/15 transition-all"
>
<RefreshCw size={14} />
Resume in New Chat
@@ -566,7 +566,7 @@ export function ConcludeSessionModal({
className={cn(
'flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-all',
copied
? 'bg-emerald-400/15 text-emerald-400 border border-emerald-400/30'
? 'bg-emerald-400/15 text-success border border-emerald-400/30'
: 'bg-primary text-white hover:brightness-110 active:scale-[0.98]'
)}
>

View File

@@ -130,6 +130,14 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
}
}, [handleMouseMove, handleMouseUp])
// Refs so the debounced save always uses the latest questions/actions/tasks
const questionsRef = useRef(questions)
const actionsRef = useRef(actions)
const tasksRef = useRef(tasks)
useEffect(() => { questionsRef.current = questions }, [questions])
useEffect(() => { actionsRef.current = actions }, [actions])
useEffect(() => { tasksRef.current = tasks }, [tasks])
// Save task state to sessionStorage on every change + debounce to backend
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
@@ -139,9 +147,9 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(() => {
aiSessionsApi.saveTaskLane(sessionId, {
questions: questions.map(q => ({ text: q.text, context: q.context })),
actions: actions.map(a => ({ label: a.label, command: a.command, description: a.description })),
responses: tasks as unknown as Array<Record<string, unknown>>,
questions: questionsRef.current.map(q => ({ text: q.text, context: q.context })),
actions: actionsRef.current.map(a => ({ label: a.label, command: a.command, description: a.description })),
responses: tasksRef.current as unknown as Array<Record<string, unknown>>,
}).catch(() => { /* silent — best-effort save */ })
}, 2000)
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
@@ -360,7 +368,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
<section>
<div className="sticky top-0 z-10 pb-2" style={{ background: 'var(--color-bg-page)' }}>
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
<span className="w-1.5 h-1.5 rounded-full bg-[#60a5fa]" />
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
Diagnostic Checks
{actionTasks.every(a => a.state === 'done' || a.state === 'skipped') && (
<Check size={10} className="text-success" />

View File

@@ -9,9 +9,9 @@ interface AISessionListItemProps {
const STATUS_CONFIG = {
active: { icon: Clock, color: 'text-primary', label: 'Active' },
paused: { icon: Pause, color: 'text-amber-400', label: 'Paused' },
resolved: { icon: CheckCircle2, color: 'text-emerald-400', label: 'Resolved' },
escalated: { icon: ArrowUpRight, color: 'text-amber-400', label: 'Escalated' },
paused: { icon: Pause, color: 'text-warning', label: 'Paused' },
resolved: { icon: CheckCircle2, color: 'text-success', label: 'Resolved' },
escalated: { icon: ArrowUpRight, color: 'text-warning', label: 'Escalated' },
abandoned: { icon: AlertCircle, color: 'text-text-muted', label: 'Abandoned' },
} as const
@@ -64,7 +64,7 @@ export function AISessionListItem({ session }: AISessionListItemProps) {
</div>
</div>
{session.session_rating && (
<span className="font-sans text-xs text-xs text-amber-400">
<span className="font-sans text-xs text-xs text-warning">
{'★'.repeat(session.session_rating)}
</span>
)}

View File

@@ -35,9 +35,9 @@ export function EscalateModal({ open, onClose, onEscalate, isProcessing, hasPsaT
return (
<Modal isOpen={open} onClose={handleClose} title="Escalate Session" size="sm">
<div className="space-y-4">
<div className="flex items-start gap-3 rounded-xl border border-amber-400/20 bg-amber-400/5 p-3">
<AlertTriangle size={16} className="text-amber-400 shrink-0 mt-0.5" />
<p className="text-sm text-amber-400">
<div className="flex items-start gap-3 rounded-xl border border-warning/20 bg-warning/5 p-3">
<AlertTriangle size={16} className="text-warning shrink-0 mt-0.5" />
<p className="text-sm text-warning">
This will mark the session as requesting escalation. Team members will see it in their escalation queue and can pick it up with full context.
</p>
</div>
@@ -70,7 +70,7 @@ export function EscalateModal({ open, onClose, onEscalate, isProcessing, hasPsaT
<button
onClick={handleSubmit}
disabled={!reason.trim() || reason.trim().length < 5 || isProcessing}
className="flex-1 flex items-center justify-center gap-2 rounded-lg bg-amber-500/90 px-4 py-2.5 min-h-[44px] text-sm font-semibold text-white hover:bg-amber-500 active:scale-[0.98] disabled:opacity-40 transition-all"
className="flex-1 flex items-center justify-center gap-2 rounded-lg bg-warning px-4 py-2.5 min-h-[44px] text-sm font-semibold text-white hover:bg-warning active:scale-[0.98] disabled:opacity-40 transition-all"
>
{isProcessing ? (
<Loader2 size={14} className="animate-spin" />

View File

@@ -91,7 +91,7 @@ export function FlowPilotActionBar({
<button
onClick={() => { setShowResolve(true); setShowEscalate(false) }}
disabled={!canResolve || isProcessing}
className="flex items-center justify-center gap-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/20 px-2.5 sm:px-4 py-2 min-h-[40px] sm:min-h-[44px] text-xs sm:text-sm font-medium text-emerald-400 hover:bg-emerald-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
className="flex items-center justify-center gap-1.5 rounded-lg bg-success-dim border border-success/20 px-2.5 sm:px-4 py-2 min-h-[40px] sm:min-h-[44px] text-xs sm:text-sm font-medium text-success hover:bg-success/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
>
<CheckCircle2 size={15} />
Resolve
@@ -99,7 +99,7 @@ export function FlowPilotActionBar({
<button
onClick={() => setShowEscalate(true)}
disabled={!canEscalate || isProcessing}
className="flex items-center justify-center gap-1.5 rounded-lg bg-amber-500/10 border border-amber-500/20 px-2.5 sm:px-4 py-2 min-h-[40px] sm:min-h-[44px] text-xs sm:text-sm font-medium text-amber-400 hover:bg-amber-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
className="flex items-center justify-center gap-1.5 rounded-lg bg-warning-dim border border-warning/20 px-2.5 sm:px-4 py-2 min-h-[40px] sm:min-h-[44px] text-xs sm:text-sm font-medium text-warning hover:bg-warning/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
>
<ArrowUpRight size={15} />
Escalate
@@ -108,7 +108,7 @@ export function FlowPilotActionBar({
<button
onClick={() => setShowStatusUpdate(true)}
disabled={isProcessing}
className="flex items-center justify-center gap-1.5 rounded-lg bg-blue-500/10 border border-blue-500/20 px-2.5 sm:px-4 py-2 min-h-[40px] sm:min-h-[44px] text-xs sm:text-sm font-medium text-blue-400 hover:bg-blue-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
className="flex items-center justify-center gap-1.5 rounded-lg bg-accent-dim border border-accent/20 px-2.5 sm:px-4 py-2 min-h-[40px] sm:min-h-[44px] text-xs sm:text-sm font-medium text-accent hover:bg-accent/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
title="Share Update"
>
<FileText size={15} />
@@ -166,7 +166,7 @@ export function FlowPilotActionBar({
<button
onClick={handleResolve}
disabled={resolutionSummary.length < 5 || submitting}
className="rounded-lg bg-emerald-500/20 border border-emerald-500/30 px-4 py-2 min-h-[44px] text-sm font-medium text-emerald-400 hover:bg-emerald-500/30 disabled:opacity-50 transition-colors"
className="rounded-lg bg-success/20 border border-success/30 px-4 py-2 min-h-[44px] text-sm font-medium text-success hover:bg-success/30 disabled:opacity-50 transition-colors"
>
{submitting ? 'Resolving...' : 'Resolve Session'}
</button>
@@ -193,7 +193,7 @@ export function FlowPilotActionBar({
<button
onClick={handleAbandon}
disabled={submitting}
className="rounded-lg bg-rose-500/20 border border-rose-500/30 px-4 py-2 min-h-[44px] text-sm font-medium text-rose-400 hover:bg-rose-500/30 disabled:opacity-50 transition-colors"
className="rounded-lg bg-danger/20 border border-danger/30 px-4 py-2 min-h-[44px] text-sm font-medium text-danger hover:bg-danger/30 disabled:opacity-50 transition-colors"
>
{submitting ? 'Closing...' : 'Close Session'}
</button>

View File

@@ -155,7 +155,7 @@ export function FlowPilotIntake({ onSubmit, isLoading, defaultProblem }: FlowPil
</div>
<button
onClick={handleClearTicket}
className="ml-2 rounded-md p-1 text-muted-foreground hover:bg-white/[0.06] hover:text-foreground transition-colors"
className="ml-2 rounded-md p-1 text-muted-foreground hover:bg-elevated hover:text-foreground transition-colors"
>
<X size={14} />
</button>
@@ -215,7 +215,7 @@ export function FlowPilotIntake({ onSubmit, isLoading, defaultProblem }: FlowPil
Pull from Ticket
</button>
{psaChecked && !psaConnection && (
<span className="flex items-center gap-1 text-[0.625rem] text-amber-400/80">
<span className="flex items-center gap-1 text-[0.625rem] text-warning/80">
<AlertTriangle size={10} />
No PSA connected
</span>

View File

@@ -243,17 +243,17 @@ export function FlowPilotMessageBar({ onRespond, disabled = false, isProcessing
<button
type="button"
onClick={() => handleRemoveUpload(upload.id)}
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-background border border-border flex items-center justify-center hover:bg-rose-500/20 transition-colors"
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-background border border-border flex items-center justify-center hover:bg-danger/20 transition-colors"
>
<X size={8} className="text-muted-foreground" />
</button>
)}
{upload.status === 'error' && (
<div
className="absolute inset-0 bg-rose-500/20 border-2 border-rose-500 flex items-center justify-center cursor-pointer"
className="absolute inset-0 bg-danger/20 border-2 border-danger flex items-center justify-center cursor-pointer"
onClick={() => retryUpload(upload.id)}
>
<RotateCcw size={10} className="text-rose-500" />
<RotateCcw size={10} className="text-danger" />
</div>
)}
</div>

View File

@@ -148,7 +148,7 @@ export function FlowPilotSession({
<div className="mb-4">
<button
onClick={() => setShowShareCommunication(true)}
className="flex items-center gap-2 rounded-lg bg-blue-500/10 border border-blue-500/20 px-4 py-2.5 text-sm font-medium text-blue-400 hover:bg-blue-500/20 transition-colors"
className="flex items-center gap-2 rounded-lg bg-accent-dim border border-accent/20 px-4 py-2.5 text-sm font-medium text-accent hover:bg-accent/20 transition-colors"
>
<FileText size={16} />
{shareLabel}

Some files were not shown because too many files have changed in this diff Show More