---
phase: 06-web-chat
plan: 01
subsystem: backend
tags: [web-chat, websocket, redis-pubsub, rbac, orm, migration]
dependency_graph:
  requires: []
  provides:
    - WebSocket endpoint at /chat/ws/{conversation_id}
    - REST API at /api/portal/chat/* for conversation CRUD
    - web_conversations and web_conversation_messages tables with RLS
    - Redis pub-sub response delivery for web channel
    - ChannelType.WEB in shared message model
  affects:
    - packages/orchestrator/orchestrator/tasks.py (new web channel routing)
    - packages/shared/shared/api/__init__.py (chat_router added)
    - packages/gateway/gateway/main.py (Phase 6 routers mounted)
tech_stack:
  added:
    - gateway/channels/web.py (FastAPI WebSocket + normalize_web_event)
    - shared/api/chat.py (conversation CRUD REST API)
    - shared/models/chat.py (WebConversation + WebConversationMessage ORM)
    - migrations/versions/008_web_chat.py (DB tables + RLS + CHECK constraint update)
  patterns:
    - WebSocket auth via first JSON message (browser cannot send custom headers)
    - Redis pub-sub for async response delivery from Celery to WebSocket
    - thread_id = conversation_id for agent memory scoping
    - try/finally around all Redis connections to prevent leaks
    - TEXT+CHECK for role column (not sa.Enum) per Phase 1 ADR
    - SQLAlchemy 2.0 Mapped[]/mapped_column() style
    - require_tenant_member RBAC guard on all REST endpoints
key_files:
  created:
    - packages/gateway/gateway/channels/web.py
    - packages/shared/shared/api/chat.py
    - packages/shared/shared/models/chat.py
    - migrations/versions/008_web_chat.py
    - tests/unit/test_web_channel.py
    - tests/unit/test_chat_api.py
  modified:
    - packages/shared/shared/models/message.py (ChannelType.WEB added)
    - packages/shared/shared/redis_keys.py (webchat_response_key added)
    - packages/shared/shared/api/__init__.py (chat_router exported)
    - packages/gateway/gateway/main.py (Phase 6 routers mounted)
    - packages/orchestrator/orchestrator/tasks.py (web channel extras + routing)
decisions:
  - "WebSocket auth via first JSON message after connection — browser WebSocket API cannot send custom HTTP headers"
  - "thread_id = conversation_id in normalize_web_event — scopes agent memory to one web conversation (consistent with WhatsApp wa_id scoping)"
  - "Redis pub-sub response delivery: orchestrator publishes to webchat_response_key, WebSocket handler subscribes with 60s timeout"
  - "TEXT+CHECK for role column ('user'/'assistant') per Phase 1 ADR — not sa.Enum"
  - "dependency_overrides used in tests instead of patching shared.db.get_session — FastAPI dependency injection doesn't follow module-level patches"
metrics:
  duration: "~8 minutes"
  completed_date: "2026-03-25"
  tasks_completed: 2
  files_created: 6
  files_modified: 5
---

# Phase 6 Plan 01: Web Chat Backend Infrastructure Summary

**One-liner:** WebSocket endpoint + Redis pub-sub response bridge + RBAC REST API providing complete web chat plumbing from portal UI to the agent pipeline.

## What Was Built

This plan establishes the complete backend for web chat — the "web" channel that lets portal users talk to AI employees directly from the Konstruct portal UI without setting up Slack or WhatsApp.

### ChannelType.WEB and Redis key
`ChannelType.WEB = "web"` added to the shared message model. `webchat_response_key(tenant_id, conversation_id)` added to `redis_keys.py` following the established namespace pattern (`{tenant_id}:webchat:response:{conversation_id}`).

### DB Schema (migration 008)
Two new tables with FORCE ROW LEVEL SECURITY:
- `web_conversations` — one per (tenant_id, agent_id, user_id) triple with UniqueConstraint for get-or-create semantics
- `web_conversation_messages` — individual messages with TEXT+CHECK role column ('user'/'assistant') and CASCADE delete
- `channel_connections.channel_type` CHECK constraint replaced to include 'web'

### WebSocket Endpoint (`/chat/ws/{conversation_id}`)
Full message lifecycle in `gateway/channels/web.py`:
1. Accept connection
2. Auth handshake via first JSON message (browser limitation)
3. For each message: typing indicator → save to DB → Celery dispatch → Redis subscribe → save response → send to client
4. try/finally cleanup on all Redis connections

### REST API (`/api/portal/chat/*`)
Four endpoints in `shared/api/chat.py`:
- `GET /conversations` — list with RBAC (platform_admin sees all, others see own)
- `POST /conversations` — get-or-create with IntegrityError race condition handling
- `GET /conversations/{id}/messages` — paginated history with cursor support
- `DELETE /conversations/{id}` — message reset keeping conversation row

### Orchestrator Integration
`tasks.py` updated:
- `handle_message` pops `conversation_id` and `portal_user_id` before `model_validate`
- `_build_response_extras` handles "web" case returning `{conversation_id, tenant_id}`
- `_send_response` handles "web" case with Redis pub-sub publish and try/finally cleanup
- `webchat_response_key` imported at module level

## Test Coverage

19 unit tests written (TDD, all passing):

| Test | Covers |
|------|--------|
| `test_webchat_response_key_format` | Key format correct |
| `test_webchat_response_key_isolation` | Tenant isolation |
| `test_channel_type_web_exists` | ChannelType.WEB |
| `test_normalize_web_event_*` (5 tests) | Message normalization CHAT-01 |
| `test_send_response_web_publishes_to_redis` | Redis pub-sub publish CHAT-02 |
| `test_send_response_web_connection_cleanup` | try/finally Redis cleanup |
| `test_send_response_web_missing_conversation_id_logs_warning` | Error handling |
| `test_typing_indicator_sent_before_dispatch` | Typing indicator CHAT-05 |
| `test_chat_rbac_enforcement` | 403 for non-member CHAT-04 |
| `test_platform_admin_cross_tenant` | Admin bypass CHAT-04 |
| `test_list_conversation_history` | Paginated messages CHAT-03 |
| `test_create_conversation` | Get-or-create CHAT-03 |
| `test_create_conversation_rbac_forbidden` | 403 for non-member |
| `test_delete_conversation_resets_messages` | Message reset |

Full 313-test suite passes.

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 1 - Bug] Test dependency injection: patch vs dependency_overrides**
- **Found during:** Task 1 test implementation
- **Issue:** `patch("shared.db.get_session")` doesn't work for FastAPI endpoint testing because FastAPI's dependency injection resolves `Depends(get_session)` at function definition time, not via module attribute lookup
- **Fix:** Used `app.dependency_overrides[get_session] = _override_get_session` pattern in test helper `_make_app_with_session_override()` — consistent with other test files in the project
- **Files modified:** `tests/unit/test_chat_api.py`

**2. [Rule 2 - Missing functionality] session.refresh mock populating server defaults**
- **Found during:** Task 1 create_conversation test
- **Issue:** Mocked `session.refresh()` was a no-op, leaving `created_at`/`updated_at` as `None` on new ORM objects (server_default not applied without real DB)
- **Fix:** Test uses an async side_effect function that populates datetime fields on the object passed to `refresh()`
- **Files modified:** `tests/unit/test_chat_api.py`

## Self-Check: PASSED

All key artifacts verified:
- `ChannelType.WEB = "web"` — present in message.py
- `webchat_response_key()` — present in redis_keys.py
- `WebConversation` ORM class — present in models/chat.py
- `chat_websocket` WebSocket endpoint — present in gateway/channels/web.py
- `chat_router` — exported from shared/api/__init__.py
- `web_conversations` table — created in migration 008
- Commits `c72beb9` and `56c11a0` — verified in git log
- 313/313 unit tests pass
