"I have an idea for an AI tool where a user types in their new business idea, and then 5 different AI bots pretend to be everyday customers and have a conversation about whether they would buy it."
MockBoard
Architecture Design Document
Architecture Design Document
This document outlines the MVP architecture for a multi-tenant B2B SaaS platform that orchestrates real-time AI focus groups. It focuses on the managed concurrency of ECS Fargate tasks and the dual-layer vector store in pgvector, providing a clean, declarative boundary suitable for an AI coding agent practicing Compound Engineering.
TL;DR
This platform lets product teams upload a PRD or pitch deck and watch a panel of AI personas debate it in real time, then receive a structured analyst report when the session ends. The two most important architectural choices are using Amazon ECS on Fargate to run per-session LangGraph orchestration workers (persistent but fully managed), and a dual-layer vector store in pgvector-on-RDS to ground each persona in real ethnographic data while keeping product context sharply separated.
Executive Summary
The platform is a multi-tenant B2B SaaS system built entirely on AWS managed services. The backend is a Python/FastAPI service that handles tenant management, session configuration, document ingestion, and report retrieval. Session orchestration runs as ephemeral ECS Fargate tasks — one task per active focus group session — each hosting a LangGraph graph that coordinates a Moderator Agent, 1–10 Persona Agents, and an Analyst Agent. Real-time debate output streams from the orchestration worker directly to clients: the worker retrieves WebSocket connection IDs from Redis and pushes agent messages to those connections via the API Gateway Management API, with no intermediate relay component.
The dual-layer RAG architecture stores two logically separate namespaces in pgvector: a base ethnographic corpus (loaded at deploy time and supplemented by user-supplied grounding data) and per-session product context chunks derived from uploaded PRDs and decks. LangGraph state checkpoints are persisted directly into RDS PostgreSQL via the native AsyncPostgresSaver — no S3 involvement in the checkpoint path. S3 is reserved strictly for raw uploaded files and final compiled analyst reports.
State persistence uses a combination of RDS (relational session metadata, team/workspace data, message history, and LangGraph checkpoints) and Amazon S3 (raw files and final reports). The 99.9% availability target is met through RDS Multi-AZ, ECS task health checks with automatic restart, and the checkpoint-based resume pattern — if a worker crashes mid-session, the next task picks up from the last committed LangGraph checkpoint in RDS.
The primary architectural tension is managed concurrency: 500 simultaneous ECS Fargate tasks, each making parallel LLM calls for up to 10 persona agents, means the system is I/O-bound and cloud-spend-sensitive. AsyncPostgresSaver checkpoint writes at 500 concurrent sessions add meaningful write pressure to RDS; the instance is sized accordingly and write IOPS is a monitored metric. Cost guardrails are built into the design from the start.
System Overview
graph TD
subgraph CLIENT["Client Layer"]
nextjs["Next.js Frontend
(Amplify)"]
end
subgraph APIGW["API Gateway Layer"]
waf["AWS WAF"]
restApi["REST API
(HTTP)"]
wsApi["WebSocket API"]
end
subgraph AUTH["Auth"]
cognito["Amazon Cognito
User Pools"]
end
subgraph BE["Backend Services"]
alb["Application
Load Balancer"]
fastapi["FastAPI
Control Plane
(ECS Fargate)"]
end
subgraph ORCH["Orchestration Workers"]
worker["LangGraph Worker
(ECS Fargate
per session)"]
moderator["Moderator Agent"]
persona["Persona Agents
(1–10)"]
analyst["Analyst Agent"]
end
subgraph INGEST["Ingestion Pipeline"]
sqs["SQS
Ingestion Queue"]
ingestTask["Ingestion Worker
(ECS Fargate)"]
end
subgraph DATA["Data Layer"]
rds["RDS PostgreSQL 16
+ pgvector
+ AsyncPostgresSaver
(Multi-AZ)"]
redis["ElastiCache Redis 7
(Connection ID Registry
+ Heartbeat)"]
s3["Amazon S3
(uploads / reports)"]
end
subgraph EXTERNAL["External AWS Services"]
bedrock["Amazon Bedrock
Claude claude-sonnet-4-5
+ Titan Embeddings v2"]
mgmtApi["API Gateway
Management API"]
ses["Amazon SES"]
secretsMgr["Secrets Manager"]
stepFn["Step Functions
(GDPR Erasure)"]
end
nextjs -->|"HTTPS REST"| waf
nextjs -->|"WebSocket connect"| wsApi
waf --> restApi
restApi -->|"JWT auth"| cognito
restApi --> alb
alb --> fastapi
wsApi -->|"store connection_id"| redis
fastapi -->|"RunTask"| worker
fastapi --> rds
fastapi --> s3
fastapi --> ses
worker --> moderator
moderator -->|"routes to"| persona
persona -->|"routes back"| moderator
moderator -->|"end session"| analyst
worker -->|"checkpoint write"| rds
worker -->|"write message + turn_sequence"| rds
worker -->|"read connection IDs"| redis
worker -->|"direct push"| mgmtApi
worker -->|"LLM calls"| bedrock
mgmtApi -->|"push messages"| nextjs
persona -->|"RAG: ethnographic
+ product context"| rds
ingestTask -->|"embed + upsert vectors"| rds
ingestTask -->|"parse files"| s3
ingestTask -->|"embed"| bedrock
s3 -->|"event notification"| sqs
sqs --> ingestTask
analyst -->|"write report"| s3
fastapi --> secretsMgr
worker --> secretsMgr
stepFn -->|"purge tenant data
+ checkpoints"| rds
stepFn -->|"delete S3 objects"| s3
Client layer: A Next.js application (React) served from AWS Amplify. Users configure and launch sessions, watch the live debate stream, and browse historical sessions and reports.
API layer: An Amazon API Gateway with two surfaces — a REST API for CRUD operations (sessions, workspaces, uploads, reports) and a WebSocket API for real-time streaming. Both route to the backend services layer.
Backend services: A containerized FastAPI application running on ECS Fargate behind an Application Load Balancer. Handles authentication (via Amazon Cognito), document ingestion pipeline, session lifecycle management, and report retrieval. This is the control plane.
Orchestration workers: Separate ECS Fargate tasks, one spawned per active session. Each task runs the LangGraph graph for the duration of the session. Workers initialize their graph state from the LangGraph checkpoint stored in RDS (via AsyncPostgresSaver) and write a new checkpoint at every node boundary. On each agent turn, the worker retrieves the active WebSocket connection IDs for the session from Redis and pushes agent messages directly to those connections via the API Gateway Management API.
Data plane: RDS PostgreSQL (Multi-AZ) with pgvector extension for vector similarity search and the LangGraph checkpoint tables for session state. Two pgvector namespaces — ethnographic for persona grounding and product_context for per-session PRD chunks. S3 holds raw uploaded files and rendered analyst reports only.
Streaming bus: ElastiCache Redis (cluster mode disabled, Multi-AZ with automatic failover). On $connect, API Gateway stores the WebSocket connection ID in Redis keyed by session_id. Orchestration workers read connection IDs from Redis and push messages directly to API Gateway Management API. Redis also carries the session heartbeat tracking.
Document ingestion: A separate ECS Fargate task (pulled from a dedicated task pool) handles PDF/deck parsing, chunking, embedding via Amazon Bedrock Titan Embeddings v2, and upsert into pgvector.
Data flow for a live session:
- User uploads PRD → ingestion pipeline chunks, embeds, and stores vectors in the
product_contextnamespace tagged to the session. - User configures personas and launches session → API layer spawns an ECS orchestration worker task.
- Worker initializes LangGraph state from the RDS checkpoint (via
AsyncPostgresSaver) or a fresh graph state for a new session. - Each agent turn: relevant ethnographic chunks are retrieved from the
ethnographicnamespace for the active persona; relevant PRD chunks are retrieved fromproduct_contextfor the session. Both are injected into the agent prompt. - Agent output is written to the RDS message history table (with an incremented
turn_sequencecounter) and LangGraph writes a checkpoint to RDS viaAsyncPostgresSaver. - The worker reads the session's connection IDs from Redis and pushes the agent message payload to each connected client via the API Gateway Management API.
- On session completion, the Analyst Agent synthesizes the full message history into a structured report, stored in S3 and indexed in RDS.
Architectural Constraints Summary
AWS lock-in is a feature here, not a limitation. The preference for fully managed services drove the choice of ECS Fargate over EKS (no control plane to manage), RDS over a self-hosted vector DB, Cognito over Auth0/Clerk, and Amplify over a custom S3/CloudFront setup. Every service in the stack has a managed upgrade path and no server to patch.
The resumability requirement is the single hardest constraint. It means orchestration cannot be stateless — every LangGraph node transition must write a checkpoint before the next node begins. Lambda is ruled out for orchestration (15-minute limit, no persistent process). AsyncPostgresSaver writes checkpoints directly into the existing RDS PostgreSQL instance, eliminating the need for any separate checkpoint store. The constraint now drives RDS instance sizing rather than an S3 snapshot strategy.
The dual-layer RAG constraint means the vector store must support logical namespace separation. pgvector satisfies this with a table-per-namespace approach. A single managed RDS instance handles both namespaces and the LangGraph checkpoint tables, avoiding the operational overhead of a separate dedicated vector DB.
GDPR drives tenant data isolation (row-level security on all RDS tables keyed to tenant_id), right-to-erasure support (soft-delete with hard-purge jobs on S3 and RDS), data residency in a single AWS region (eu-west-1 or us-east-1 per tenant agreement), and no cross-tenant vector leakage (product context vectors are always scoped to session_id).
The concurrency target (50–500 sessions) resolved the scaling model: ECS Service Auto Scaling based on active session count, with a pre-warmed minimum task count to absorb burst. At 500 simultaneous tasks, AsyncPostgresSaver checkpoint writes become a meaningful RDS write workload — the instance is sized at db.r7g.large minimum with write IOPS monitored and alerting configured. The LLM API remains the binding constraint for session throughput; rate limit management is built into the Persona Agent layer.
The streaming architecture change (removing the Lambda relay) is driven by the desire to reduce components. The orchestration worker is already a persistent ECS task with network access — it can call the API Gateway Management API directly after reading connection IDs from Redis, making a dedicated relay Lambda redundant and eliminating one failure surface.
Component Architecture
Next.js Frontend (Amplify)
Responsible for all user-facing interactions. Uses React Server Components for static shell pages (workspace dashboard, session history) and client components for the live session view. The live session view opens a WebSocket connection to API Gateway on mount and appends incoming agent messages to a local state buffer rendered as a debate transcript. The session configuration wizard (persona count, archetype selection, topic framing) is a multi-step form that submits to the REST API. File upload uses pre-signed S3 URLs — the frontend requests a URL from the REST API, uploads directly to S3, then notifies the API of upload completion. No file bytes transit the API layer.
API Gateway (REST + WebSocket)
Two separate API Gateway instances. The REST API uses Lambda integrations for lightweight CRUD operations and proxies longer operations (session launch) to the FastAPI ALB. The WebSocket API manages connection lifecycle — $connect, $disconnect, and $default routes. On $connect, a Lambda stores the connection ID in Redis as a member of a set keyed by session_id, along with the API Gateway Management API endpoint URL needed for the push. On $disconnect, a Lambda removes the connection ID from that set. There is no long-running relay Lambda — the orchestration worker handles all outbound pushes directly. Connection IDs are stored in Redis with a TTL matching the maximum session duration.
FastAPI Backend (ECS Fargate — Control Plane)
Handles: tenant onboarding, workspace and team management, session CRUD (create, list, get, delete), persona archetype management, triggering document ingestion, spawning orchestration worker tasks via the ECS API, polling session state, and serving analyst reports. Runs as a persistent ECS Fargate service (not per-request Lambda) because it manages long-lived session state transitions and needs stable outbound connections to RDS and ElastiCache. Scales horizontally behind the ALB; target tracking on CPU utilization at 60%.
Authentication is verified via Cognito JWT on every request. Row-level security is enforced at the application layer — every database query is scoped to the tenant_id extracted from the JWT.
Document Ingestion Pipeline
Triggered by an S3 event notification when a raw file lands in the uploads bucket. An SQS queue decouples the event from processing. An ECS Fargate task (pulled from a dedicated task pool) consumes the SQS message, fetches the file from S3, parses it (PDFs via PyMuPDF, PowerPoint via python-pptx), chunks it into ~512-token segments with 10% overlap, generates embeddings via Bedrock Titan Embeddings v2, and upserts the vectors into the product_context namespace in pgvector, tagged with session_id and tenant_id. On completion, the task updates the session record in RDS with ingestion status and notifies the FastAPI service via an internal SQS completion queue.
LangGraph Orchestration Worker (ECS Fargate — per session)
The most complex component. Spawned by the FastAPI service via the ECS RunTask API call. Each task receives its session configuration (session ID, persona configs, topic, model parameters) via environment variables at task launch.
State persistence: On startup the worker opens an AsyncPostgresSaver connection to RDS and either resumes from the latest checkpoint for the given thread ID (if resuming an interrupted session) or initializes a fresh graph state. At every node boundary, LangGraph commits a checkpoint row to the RDS checkpoint tables before advancing to the next node. The session record in RDS no longer tracks a snapshot S3 key — the checkpoint is always fetched from RDS directly.
Streaming push: After each agent turn is written to the message history table, the worker reads the set of active connection IDs for the session from Redis, then calls the API Gateway Management API for each connection ID to push the message payload. Stale connection IDs (where the Management API returns a gone response) are removed from the Redis set immediately.
Graph topology: The LangGraph graph is cyclic. The Moderator Agent node runs first, selects the next persona to speak (or decides the round is complete), then transitions to the selected Persona Agent node. After each persona speaks, control returns to the Moderator. After a configurable number of rounds, the Moderator transitions to the Analyst Agent node, which produces the final report and transitions to a terminal node. A checkpoint is committed at the exit of every node.
Turn sequencing: The LangGraph graph state includes a global turn counter. The counter is incremented by the Moderator at the start of each agent turn and is passed into the RDS message history insert as the turn_sequence value. This guarantees absolute sequential ordering of messages independent of timestamp resolution or clock skew across retry scenarios.
Moderator Agent: Tracks which personas have spoken, how many rounds have elapsed, and whether the conversation has reached the configured topic depth. Uses a Claude claude-sonnet-4-5 call to generate a structured facilitation message and a routing decision (which persona speaks next, or end the round). The routing decision is the LangGraph conditional edge. The Moderator also injects the current debate context into each persona prompt to maintain coherence.
Persona Agent: Each persona is configured with an archetype (e.g., "skeptical enterprise buyer," "early adopter founder") and optional user-supplied grounding documents. On each turn, the agent queries the ethnographic pgvector namespace for the top-k most relevant chunks given the current debate topic and the persona's archetype embedding. It also queries the product_context namespace for the top-k most relevant PRD chunks given the Moderator's question. Both retrieval results are injected into the system prompt before the Claude claude-sonnet-4-5 call. The agent's output is the persona's spoken response, written to the RDS message history table (with the current turn_sequence value) and pushed to clients via the Management API.
Analyst Agent: Runs once at the end of the session. Receives the full message history from RDS ordered by turn_sequence (not from the graph state, to avoid token limit issues with large sessions). Uses Claude claude-sonnet-4-5 to generate a structured report covering: positioning findings, pricing sensitivity signals, feature prioritization, and dissenting persona views. The report is stored as a structured JSON document in S3 and a summary is written to RDS.
Rate limit management: The worker maintains a token bucket per LLM provider. Persona agents within a session are called sequentially (dictated by the Moderator's routing), not in parallel, so the per-session LLM call rate is bounded. Across 500 sessions this is still high — exponential backoff with jitter is applied on all LLM calls, and Bedrock throughput quotas are requested proactively.
Heartbeat: The worker publishes a heartbeat message to its Redis key every 30 seconds. The FastAPI service monitors heartbeats; if a worker misses three consecutive heartbeats, the session is marked suspended and a new ECS task is spawned to resume from the latest RDS checkpoint.
Ethnographic Corpus Loader
A one-time (and periodically re-run) batch ECS task that loads the base ethnographic corpus into the ethnographic pgvector namespace. Also handles ingestion of user-supplied grounding files (uploaded separately from PRDs). These vectors are tagged with tenant_id for user-supplied data and corpus: base for the shared ethnographic baseline. Persona agents query both during retrieval.
Data Architecture
Storage Systems
- RDS PostgreSQL 16 (Multi-AZ, db.r7g.large minimum) — relational metadata, message history, session state, team/workspace data, pgvector RAG namespaces, and LangGraph
AsyncPostgresSavercheckpoint tables. Write IOPS is a monitored metric given checkpoint volume at 500 concurrent sessions; instance class should be upgraded to db.r7g.xlarge if sustained write IOPS exceed 3,000. - Amazon S3 — raw uploaded files and final compiled analyst reports only. No checkpoint or snapshot data.
- ElastiCache Redis 7.x (cache.r7g.large, Multi-AZ with automatic failover) — WebSocket connection ID registry (sets keyed by session_id), session heartbeat tracking.
Core Entities
| Entity | Attribute | Type + Constraints | Business Rule & Access Control |
|---|---|---|---|
| Tenant | id | UUID, PK | Root isolation unit; all other entities reference tenant_id |
| Tenant | gdpr_region | ENUM('eu', 'us'), NOT NULL | Determines which AWS region data is stored in; enforced at account creation |
| Tenant | erasure_requested_at | TIMESTAMPTZ, nullable | Triggers scheduled hard-purge job; access restricted to platform admins |
| Workspace | id | UUID, PK | Belongs to exactly one tenant; teams are workspace-scoped |
| Workspace | tenant_id | UUID, FK(Tenant), NOT NULL | Row-level security predicate; all queries filtered by this |
| User | id | UUID, PK (Cognito sub) | Cognito is source of truth for identity; local record is profile/preference store |
| User | tenant_id | UUID, FK(Tenant), NOT NULL | Must match the tenant of every workspace they belong to |
| WorkspaceMember | workspace_id + user_id | Composite PK | Junction table; role ENUM('owner', 'editor', 'viewer') |
| Session | id | UUID, PK | Core orchestration unit |
| Session | workspace_id | UUID, FK(Workspace), NOT NULL | Inherits tenant isolation from workspace |
| Session | status | ENUM('configuring', 'ingesting', 'running', 'paused', 'completed', 'failed'), NOT NULL | State machine enforced at the API layer; only valid transitions allowed |
| Session | current_round | INT, DEFAULT 0 | Tracks progress; used by Moderator Agent for round-limit enforcement |
| Session | persona_count | INT, CHECK(1..10), NOT NULL | Enforced at session creation; drives worker task sizing |
| PersonaConfig | id | UUID, PK | Belongs to a session |
| PersonaConfig | archetype | TEXT, NOT NULL | Used as the base retrieval query against ethnographic namespace |
| PersonaConfig | grounding_doc_s3_key | TEXT, nullable | User-supplied grounding file; ingested into ethnographic namespace with tenant_id tag |
| Message | id | UUID, PK | Append-only; never updated |
| Message | session_id | UUID, FK(Session), NOT NULL | All messages for a session retrievable in order |
| Message | turn_sequence | INT, NOT NULL | Global turn counter incremented by the LangGraph state on each turn; absolute ordering guarantee independent of timestamp; set by the orchestration worker from graph state |
| Message | agent_role | ENUM('moderator', 'persona', 'analyst'), NOT NULL | Used for display rendering and analyst context assembly |
| Message | persona_config_id | UUID, FK(PersonaConfig), nullable | Populated for persona messages; null for moderator and analyst |
| Message | content | TEXT, NOT NULL | Raw agent output; GDPR erasure replaces content with '[erased]' |
| Message | created_at | TIMESTAMPTZ, DEFAULT NOW() | Secondary timestamp; used for debugging and audit; ordering uses turn_sequence |
| AnalystReport | id | UUID, PK | One per completed session |
| AnalystReport | session_id | UUID, FK(Session), UNIQUE | One-to-one with session |
| AnalystReport | s3_key | TEXT, NOT NULL | Full structured JSON report in S3 |
| AnalystReport | summary | TEXT | Short text summary stored in RDS for listing views |
| EthnographicChunk | id | UUID, PK | Lives in pgvector table in ethnographic namespace |
| EthnographicChunk | tenant_id | UUID, nullable | NULL for base corpus chunks; set for user-supplied grounding |
| EthnographicChunk | embedding | vector(1536), NOT NULL | Bedrock Titan Embeddings v2 output |
| EthnographicChunk | source_doc_s3_key | TEXT | Traceability back to source; used for GDPR deletion |
| ProductContextChunk | id | UUID, PK | Lives in pgvector table in product_context namespace |
| ProductContextChunk | session_id | UUID, FK(Session), NOT NULL | Strictly session-scoped; never shared across sessions |
| ProductContextChunk | tenant_id | UUID, NOT NULL | Belt-and-suspenders isolation |
| ProductContextChunk | embedding | vector(1536), NOT NULL | Bedrock Titan Embeddings v2 output |
LangGraph Checkpoint Tables
AsyncPostgresSaver creates and manages its own checkpoint tables in RDS (typically named checkpoints and checkpoint_writes). These tables are keyed by thread_id (mapped to session_id) and store the serialized LangGraph graph state at each node boundary. These tables are managed by the LangGraph library and should not be manually modified; however, they must be included in GDPR erasure (rows keyed to a session belonging to the erased tenant must be deleted) and in RDS backup coverage. At 500 concurrent sessions each writing a checkpoint per node transition, these tables are the primary driver of RDS write IOPS.
Data Lifecycle
Raw uploads: Stored in S3 under uploads/{tenant_id}/{session_id}/. Retained for the duration of the tenant account. GDPR erasure deletes the S3 object and all derived product context chunks.
LangGraph checkpoints: Written to RDS AsyncPostgresSaver tables, keyed by session thread ID. The latest checkpoint is always the most recent row for that thread — AsyncPostgresSaver manages its own versioning. No S3 involvement. Old checkpoints for completed sessions are eligible for pruning after 30 days via a scheduled cleanup job that deletes checkpoint rows for sessions in completed or failed status older than the retention window.
Message history: Appended to RDS for the life of the session, ordered by turn_sequence. Retained per tenant data retention policy (default 2 years). GDPR erasure replaces message content with [erased].
Analyst reports: S3 key stored in RDS; the JSON blob lives in S3. Retained alongside the session. GDPR erasure deletes the S3 object.
Product context vectors: Scoped to session. Purged from pgvector when the session is deleted or on GDPR erasure.
Ethnographic vectors (base corpus): Shared across all tenants. Not subject to GDPR erasure (no PII). Re-indexed on corpus updates.
Ethnographic vectors (user-supplied): Tagged with tenant_id. Purged on GDPR erasure.
Integration Architecture
Anthropic Claude via Amazon Bedrock
All LLM calls go through Amazon Bedrock's managed Claude endpoint, not the Anthropic API directly. This keeps API key management inside AWS IAM (no external secrets) and makes throughput quota management a native AWS operation. The Moderator, Persona, and Analyst agents all call Claude claude-sonnet-4-5 via Bedrock with streaming responses enabled — each token streams back to the worker, which assembles the full response and pushes the complete message to clients via the API Gateway Management API. Bedrock's managed retry and throttling behavior handles transient rate limits; the worker additionally applies exponential backoff with jitter for sustained overload. If Bedrock is unavailable for more than 60 seconds, the session is paused, the LangGraph state checkpoint is committed to RDS via AsyncPostgresSaver, and the user receives a "session paused — resumable" notification via the Management API push.
Amazon Bedrock Titan Embeddings v2
Used by both the document ingestion pipeline and the ethnographic corpus loader to produce 1536-dimension embeddings. The ingestion task calls Bedrock Titan synchronously per chunk; on Bedrock unavailability the SQS message is returned to the queue with a visibility timeout backoff (not deleted), and the ingestion task retries up to five times before moving the message to a dead-letter queue and marking the session as ingestion_failed.
Amazon Cognito
Cognito User Pools handle user registration, login, MFA, and token issuance. The Next.js frontend uses the Amplify Auth SDK to manage the token lifecycle (sign-in, refresh, sign-out). Every REST and WebSocket API call passes the Cognito ID token in the Authorization header; API Gateway validates the token against the Cognito JWKS endpoint before invoking any Lambda or backend route. User creation in Cognito triggers a Lambda post-confirmation hook that creates the local User record in RDS and assigns the user to a default workspace. If Cognito is unavailable, API Gateway rejects all requests with an authentication error — there is no fallback auth path.
Amazon S3
Two distinct bucket purposes: (1) raw uploads, accessed via pre-signed URL for upload (frontend → S3 direct) and via IAM role for read (ingestion tasks, orchestration workers reading persona grounding files); (2) reports, accessed by the FastAPI API for serving download URLs via pre-signed GET. S3 event notifications on the uploads bucket trigger the ingestion SQS queue. All buckets have versioning enabled, server-side encryption with AWS KMS, and Block Public Access enforced. A bucket lifecycle policy enforces region-based data residency for GDPR.
API Gateway Management API (WebSocket Push)
Orchestration workers call the API Gateway Management API directly to push messages to connected clients. The worker retrieves the set of active connection IDs for the session from Redis, then issues a push call per connection ID. If a push returns a "gone" response (the client has disconnected without a clean $disconnect event), the worker removes that connection ID from the Redis set and continues to the remaining connections. This integration is authenticated via the worker task's IAM role, which is granted permission to post to the Management API endpoint for the WebSocket API stage.
Amazon SES
Sends transactional emails: workspace invitation, session completion notification, GDPR erasure confirmation. The FastAPI service calls SES when these events occur. If SES is unavailable, the event is logged and queued in an SQS dead-letter queue for retry; email delivery failure is non-blocking — it does not fail the session or the GDPR erasure.
ECS RunTask API (internal)
The FastAPI control plane calls the ECS RunTask API to spawn orchestration worker tasks. This is an internal AWS API call, authenticated via the FastAPI service's ECS task IAM role. If the RunTask call fails (ECS capacity issue), the FastAPI service retries three times with exponential backoff, then marks the session as failed and notifies the user. No partial state is created — the session remains in configuring status until a task is successfully started.
Security Architecture
Authentication and Authorization
Cognito User Pools with email+password and optional TOTP MFA. JWT tokens (ID token) are validated at API Gateway before any backend logic runs. The FastAPI backend extracts tenant_id and user_id from the validated token and applies these as query predicates on every database operation. No query is issued without a tenant scope. Workspace-level authorization (owner/editor/viewer) is checked in the FastAPI service before session mutations.
Tenant Data Isolation
Row-level filtering is enforced at the application layer (all queries include a tenant scope predicate). RDS row-level security policies provide a defense-in-depth backstop — a compromised application-layer query still cannot return cross-tenant data. Product context vectors in pgvector include both tenant_id and session_id on every row; ANN queries always filter by both. Ethnographic vectors are either base corpus (no PII, shared) or tenant-scoped (filtered by tenant_id). LangGraph checkpoint rows are keyed by thread_id (session ID), and GDPR erasure must also purge checkpoint rows for the erased tenant's sessions.
Secrets Management
All secrets (RDS credentials, Redis auth token, internal service keys) are stored in AWS Secrets Manager. ECS tasks retrieve secrets at task startup via the native ECS Secrets integration — secrets are injected as environment variables from Secrets Manager, never hardcoded or stored in ECR images. Secret rotation is enabled for RDS credentials (automatic 30-day rotation). No secrets appear in CloudWatch logs — the logging configuration masks environment variables.
Input Validation
File uploads: validated for MIME type (PDF, PPTX, PPTM, KEY accepted) and maximum file size (50 MB default) before pre-signed URL generation. The ingestion pipeline additionally validates parsed file structure before chunking. Session configuration: persona count is validated against the 1–10 range at API layer. All user-provided text fields (persona archetype names, topic descriptions) are treated as untrusted input — passed through to LLM prompts only after sanitization that strips control characters and enforces maximum lengths. LLM prompt injection is mitigated by placing user-supplied content in clearly delimited sections of the prompt, never in the instruction segment.
Network Security
All ECS tasks run in private subnets. RDS and ElastiCache are in isolated private subnets with no internet-facing routes. Traffic from ECS tasks to S3 and Bedrock travels via VPC endpoints (Gateway endpoint for S3, Interface endpoints for Bedrock and Secrets Manager) — no traffic leaves the AWS network. Orchestration workers access the API Gateway Management API over the public internet endpoint (as is standard for API Gateway) using HTTPS with IAM-signed requests. API Gateway and the ALB are the only internet-facing endpoints. Security groups are least-privilege: the FastAPI service SG allows inbound only from the ALB; worker tasks allow no inbound traffic; RDS SG allows inbound only from the FastAPI and worker SGs; Redis SG allows inbound only from the FastAPI and worker SGs.
Web-Facing Hardening
API Gateway enforces HTTPS-only (HTTP requests are rejected at the gateway). CORS policy on the REST API allows only the Amplify-hosted frontend origin (no wildcard). Response headers include: Strict-Transport-Security (max-age 31536000, includeSubDomains), X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Content-Security-Policy restricting script sources to the application origin and CDN. AWS WAF is attached to API Gateway with rules for rate limiting (per IP, per user), SQL injection detection, and oversized request body rejection.
GDPR Compliance
Data residency: tenant data stored in the region specified at account creation (eu-west-1 for EU tenants). Right to erasure: a GDPR erasure request triggers a workflow that soft-deletes the user record, replaces all message content with [erased], deletes all S3 objects under the user's tenant prefix, purges tenant-scoped pgvector rows, and deletes AsyncPostgresSaver checkpoint rows for all sessions belonging to the erased tenant. The workflow is implemented as a Step Functions state machine to ensure all steps complete (with retry) even if partial failures occur. A completion audit log entry is written to a separate, append-only RDS table. Data minimization: only data required for platform function is collected; no third-party analytics SDKs with cross-site tracking.
Error Handling Policy
Errors returned to clients are always generic (no stack traces, no internal identifiers, no database error messages). Full error details are logged to CloudWatch Logs with a correlation ID. The correlation ID is returned to the client so support can trace it.
Deployment Architecture
AWS Region and Environment Strategy
Two environments: production and staging. Both run in the same AWS account in the same region (us-east-1 as default; can be eu-west-1 for GDPR primary tenants). Resources are separated by name prefix (prod- / staging-). A separate AWS account for staging is the eventual target but out of scope for the initial build.
Infrastructure as Code
All AWS resources defined in AWS CDK (TypeScript). CDK stacks are organized by concern: VPC/networking, data (RDS, ElastiCache, S3), compute (ECS cluster, task definitions), and application (API Gateway, Cognito, Amplify). CDK deployments are idempotent and run via GitHub Actions.
CI/CD Pipeline
GitHub Actions workflows:
- On PR: lint, type-check, unit tests, CDK diff (shows infra changes without deploying).
- On merge to
main: build Docker images, push to ECR, CDK deploy to staging, run integration test suite, require manual approval gate, then CDK deploy to production.
Docker images are built for two targets: the FastAPI control plane service and the LangGraph orchestration worker. Images are tagged with the Git commit SHA. ECS services are updated with a rolling deployment (minimum 100% healthy, maximum 200%) so there is no downtime during deploy. Orchestration worker tasks are not updated mid-session — in-flight sessions continue on the old image; new sessions spawn tasks from the new image.
ECS Fargate Configuration
FastAPI control plane: ECS Service with Application Auto Scaling. Min 2 tasks, max 20 tasks. Target tracking on ALB request count per target (target: 1000 requests per minute per task). Task size: 1 vCPU, 2 GB RAM.
Orchestration workers: Launched via RunTask (not a persistent service). Each task: 2 vCPU, 4 GB RAM. No auto-scaling policy — one task per session, bounded by the 500-session concurrency limit enforced at the API layer. ECS capacity provider: Fargate Spot for cost reduction (with Fargate on-demand as fallback — if the Fargate Spot task is interrupted, the heartbeat mechanism detects the missed heartbeat and resumes from the RDS checkpoint on a new on-demand task).
Ingestion workers: ECS Service consuming from SQS. Min 0 tasks, max 10 tasks (scales with SQS queue depth).
RDS Configuration
PostgreSQL 16, Multi-AZ, db.r7g.large (upgrade path to db.r7g.xlarge if write IOPS from AsyncPostgresSaver checkpointing at 500 concurrent sessions sustains above 3,000 IOPS). Storage: 500 GB gp3, auto-scaling enabled up to 2 TB. Automated backups retained 7 days, PITR enabled. Parameter group tuned for pgvector workloads (increased shared_buffers, work_mem). Write IOPS CloudWatch alarm threshold: 3,000 IOPS sustained for more than 5 minutes triggers an alert to notify the team of the need to evaluate an instance class upgrade.
Monitoring and Observability
CloudWatch Container Insights: ECS task-level CPU, memory, and network metrics. Alarms on: FastAPI task count < 2 (service degradation), orchestration worker task failure rate > 5% (LLM or checkpoint issues), SQS dead-letter queue depth > 0 (ingestion failures).
CloudWatch Logs: Structured JSON logs from all services. Log groups per service with 90-day retention. Correlation IDs on all log entries.
CloudWatch Alarms → SNS → PagerDuty (or email): P1 alarms for RDS Multi-AZ failover, ElastiCache node failure, API Gateway 5xx rate > 1%, RDS write IOPS > 3,000 sustained.
AWS X-Ray: Distributed tracing enabled on FastAPI and orchestration workers. Trace IDs propagate through all LLM calls and database queries.
Scaling at 500 Sessions
At 500 concurrent sessions: 500 Fargate tasks × 2 vCPU = 1,000 vCPU demand. Fargate Spot pools are requested with a Fargate on-demand fallback. A concurrency cap of 500 active sessions is enforced at the API layer — session launch returns a capacity_unavailable error above this limit rather than silently degrading. The Bedrock service quota for Claude claude-sonnet-4-5 tokens per minute is the binding constraint and must be requested via AWS quota increase before launch. RDS write capacity for AsyncPostgresSaver checkpoint writes is the second binding constraint and must be verified under load in staging.
Technology Stack
| Technology | Version / Model | Role | Why chosen |
|---|---|---|---|
| Next.js | 14 (App Router) | Frontend framework | Constraints specify React/Next.js; App Router enables RSC for static shell, client components for live stream |
| React | 18 | UI library | Specified in constraints |
| AWS Amplify (Hosting) | Gen 2 | Frontend hosting, Cognito integration | Fully managed, zero-ops, native Cognito SDK support |
| FastAPI | 0.111 | REST API / control plane | Fast Python async framework; compatible with LangGraph Python ecosystem |
| Python | 3.12 | Backend runtime | LangGraph and LangChain are Python-native; best ecosystem fit |
| LangGraph | 0.2.x | Multi-agent orchestration | Specified in constraints; cyclic graph support and native AsyncPostgresSaver are key capabilities |
| LangGraph AsyncPostgresSaver | 0.2.x (bundled) | LangGraph checkpoint persistence | Native LangGraph persistence backend; writes checkpoints directly to RDS PostgreSQL; eliminates need for separate snapshot store |
| Claude claude-sonnet-4-5 (via Bedrock) | claude-sonnet-4-5 | All LLM agent calls | Strong instruction-following for structured agent roles; available via Bedrock (no external API key) |
| Amazon Bedrock Titan Embeddings v2 | v2 | Embedding generation | AWS-native, no external API key, 1536-dim vectors, managed throughput |
| Amazon ECS Fargate | — | Control plane + worker + ingestion container hosting | Fully managed, no EC2 to patch; Fargate Spot for cost on workers |
| Amazon RDS PostgreSQL + pgvector | PostgreSQL 16, pgvector 0.7 | Relational data + vector store (dual-layer RAG) + LangGraph checkpoints | Single managed service covers relational, vector, and checkpoint storage needs; eliminates Pinecone and S3 snapshot operational overhead |
| Amazon ElastiCache Redis | Redis 7.x | WebSocket connection ID registry + session heartbeat tracking | Managed Redis with Multi-AZ failover; lightweight fast reads for connection ID lookup by workers |
| Amazon API Gateway | v2 (HTTP + WebSocket) | REST API surface + WebSocket connection management + Management API for push | Fully managed; WebSocket API native support; Management API enables worker-direct push |
| Amazon S3 | — | File storage (raw uploads and analyst reports only) | Durable, cheap, native ECS/Lambda access via IAM |
| Amazon Cognito | — | Authentication, JWT issuance, MFA | AWS-native auth; API Gateway native integration; no external auth vendor |
| Amazon SQS | — | Ingestion job queue, internal async events | Managed queue; decouples ingestion from upload event |
| Amazon Step Functions | Standard Workflows | GDPR erasure orchestration | Durable, managed workflow for multi-step erasure with retry |
| AWS Secrets Manager | — | All secrets storage and rotation | Native ECS integration; automatic rotation |
| AWS CDK | 2.x (TypeScript) | Infrastructure as Code | Type-safe; AWS-native; avoids Terraform external dependency |
| AWS WAF | v2 | API Gateway protection | Managed rules for rate limiting and injection detection |
| AWS X-Ray | — | Distributed tracing | Native ECS + API Gateway integration; no agent to manage |
| GitHub Actions | — | CI/CD | No-ops, widely used, integrates with CDK |
| PyMuPDF | 1.24.x | PDF parsing in ingestion pipeline | Fast, pure-Python, no external service dependency |
| python-pptx | 0.6.x | PowerPoint parsing | Standard Python library for PPTX extraction |
| Amazon SES | v2 | Transactional email | AWS-native; no external email vendor |
Key Architectural Decisions
ADR-1: ECS Fargate over AWS Lambda for orchestration workers Decided to run LangGraph workers as long-running ECS Fargate tasks, not Lambda functions. Alternatives: Lambda (serverless, zero-ops), EKS (more control). Lambda was rejected because of the 15-minute execution limit — focus group sessions are expected to run 30–90 minutes, and LangGraph requires a persistent process to hold in-memory graph state between node evaluations. EKS was rejected because it requires managing the control plane and node groups, conflicting with the small-team, low-ops constraint. Fargate gives container isolation, per-task billing, and no server management.
ADR-2: pgvector on RDS over a dedicated vector database (Pinecone / Qdrant) Decided to use pgvector in the existing RDS PostgreSQL instance for both RAG namespaces instead of a separate vector database. Alternative: Pinecone (managed, purpose-built). Pinecone adds a third managed service, an external API key, and cross-service latency. pgvector on RDS consolidates relational and vector queries into one managed instance, simplifies GDPR erasure (a single DELETE statement removes both relational and vector data for a session), and is sufficient for 500-session concurrency with HNSW indexing on the vector columns.
ADR-3: Worker-direct push to API Gateway Management API over a dedicated relay Lambda Decided that orchestration workers push messages directly to connected WebSocket clients via the API Gateway Management API, after reading connection IDs from Redis. The previous design used a long-running Lambda relay subscriber listening to Redis pub/sub. That relay Lambda was removed because the orchestration worker is already a persistent, network-capable ECS task that can call the Management API directly — the relay was a redundant intermediary with its own failure surface. Redis is now used only as a connection ID registry (sets keyed by session_id) and a heartbeat store, not as a pub/sub message bus.
ADR-4: Bedrock for all LLM calls over direct Anthropic API Decided to route all Claude calls through Amazon Bedrock rather than calling the Anthropic API directly. Alternatives: Anthropic API, Azure OpenAI. Bedrock is AWS-native — IAM handles authentication (no external API key to rotate or leak), throughput quotas are manageable via AWS Service Quotas, and billing is consolidated with the rest of the AWS infrastructure. This also keeps all traffic inside the AWS network via VPC endpoints, satisfying the GDPR data residency requirement without additional configuration.
ADR-5: One ECS task per session for orchestration workers Decided that each active session spawns exactly one dedicated ECS Fargate task, rather than a shared worker pool multiplexing sessions. Alternative: a worker pool where tasks handle multiple sessions. A dedicated task per session means the LangGraph graph state is fully in-memory within the task (no cross-session state sharing bugs), crashes are isolated to a single session, and resumability is clean (kill the task, checkpoint is in RDS, start a new task). The cost tradeoff is accepted: Fargate Spot pricing and the 500-session cap keep the bill predictable.
ADR-6: AsyncPostgresSaver in RDS for LangGraph checkpoints over S3 JSON blobs
Decided to use LangGraph's native AsyncPostgresSaver to write state checkpoints directly into RDS PostgreSQL, replacing the prior design that serialized snapshots as JSON blobs to S3. Alternatives: S3 JSON blobs (previous approach), Redis. AsyncPostgresSaver is the idiomatic LangGraph persistence mechanism and is designed for exactly this use case — it creates and manages its own checkpoint tables in PostgreSQL, versioned by thread ID. This eliminates the operational complexity of managing S3 lifecycle policies for checkpoint objects, removes the need to track a latest_snapshot_s3_key field on the Session table, and consolidates all persistent state into the existing managed RDS instance. The tradeoff is that checkpoint writes add to RDS write IOPS at high concurrency; this is accepted and mitigated by sizing the RDS instance at db.r7g.large minimum with IOPS monitoring and a clear upgrade path to db.r7g.xlarge. S3 is now strictly reserved for raw document uploads and final compiled analyst reports.
ADR-7: turn_sequence integer for absolute message ordering
Decided to add an integer turn_sequence column to the Message table, populated from a global turn counter maintained in the LangGraph graph state. The Moderator Agent increments the counter at the start of each agent turn and passes it into the database insert. Alternative: rely on created_at timestamp for ordering. Timestamps are insufficient because clock skew, retry scenarios, and sub-millisecond writes from the same worker can produce ambiguous ordering. The turn_sequence value is authoritative for message ordering, transcript replay, and analyst report construction.
Error Handling & Failure Modes
Document Ingestion Failures
When a user uploads a file that cannot be parsed (corrupted PDF, unsupported format variant), the ingestion task catches the parsing error, moves the SQS message to the dead-letter queue after five retries, and marks the session status as ingestion_failed in RDS. The FastAPI service detects this status and sends a notification to the session owner via SES email. The raw file is retained in S3 for debugging. The user is prompted to re-upload.
What happens if Bedrock Titan Embeddings is unavailable during ingestion? The ingestion task catches the Bedrock service error and returns the SQS message to the queue with a backoff visibility timeout (30s, 60s, 120s, 300s, 600s). After five failed attempts, the message goes to the dead-letter queue, the session is marked ingestion_failed, and the user is notified. The SQS message retention period is set to 24 hours, giving the Bedrock service time to recover for non-DLQ retries.
Orchestration Worker Failures
What happens if an orchestration worker ECS task crashes mid-session? The task stops publishing heartbeats to Redis. The FastAPI control plane's heartbeat monitor (running on a 30-second polling loop) detects three consecutive missed heartbeats (90 seconds of silence). It marks the session as suspended, then calls ECS RunTask to launch a new worker task with the same session configuration. The new task opens an AsyncPostgresSaver connection to RDS and fetches the latest checkpoint for the session's thread ID, resuming the LangGraph graph from the last committed node. Any messages written to RDS before the crash are already persisted with their turn_sequence values intact; the resumed session continues the counter from where it left off. Connected clients receive a "session suspended — resuming" notification via the Management API push from the new worker once it starts. The maximum data loss is one in-progress agent turn — the turn that was executing when the crash occurred is not written to message history, and the graph resumes from the prior committed checkpoint.
What happens if an AsyncPostgresSaver checkpoint write fails at a node boundary? The worker retries the checkpoint write three times with exponential backoff. If all retries fail, the worker pauses the session (stops advancing the graph), pushes a "session paused" message to clients via the Management API, and marks the session paused in RDS. The session remains resumable from the last successful checkpoint. The user is notified.
LLM Call Failures (Bedrock Claude)
Transient throttling (rate limit) from Bedrock: the worker retries with exponential backoff and jitter (starting at 2 seconds, up to 60 seconds, maximum 5 retries). If retries are exhausted, the agent turn fails, the session is paused, the current LangGraph state is checkpointed to RDS via AsyncPostgresSaver, and the user is notified via Management API push that the session is paused and can be resumed. The incomplete agent turn is not written to message history.
If Bedrock Claude is unavailable for more than 60 seconds: same pause-and-checkpoint behavior as above. The session is resumable when Bedrock recovers.
WebSocket Connection Failures
If a client's WebSocket connection drops during a live session, the client reconnects (the frontend retries with exponential backoff up to 5 attempts). On reconnect, the client fetches the full message history from the REST API ordered by turn_sequence to rebuild the transcript, then re-subscribes to the WebSocket stream. On $connect, the new connection ID is stored in Redis under the session's key set. The session continues unaffected — the worker pushes to whatever connection IDs are in Redis at the time of each publish.
If a worker's push to a connection ID returns a "gone" response from the Management API (client disconnected without a clean $disconnect event): the worker removes that connection ID from the Redis set immediately and continues pushing to the remaining connection IDs. No session state is affected.
If the Redis connection ID store is unavailable (ElastiCache node failure): ElastiCache Multi-AZ with automatic failover means the Redis primary fails over to the replica within 30–60 seconds. During failover, worker push attempts that cannot read connection IDs will silently skip the push (no connection IDs retrieved). Messages are still written to RDS with correct turn_sequence values. Clients are unaffected in-session — they receive no messages during the failover window, then the worker resumes pushing when Redis recovers. Clients can fetch any missed messages from the REST API by turn_sequence offset.
API Layer Failures
If the FastAPI control plane ECS service has no healthy tasks: the ALB returns a 503 to API Gateway, which returns a generic error to the client. In-flight orchestration worker tasks are unaffected — they do not depend on the control plane to continue running. The ALB health check is tuned to mark tasks healthy quickly after restart (10-second interval, 2 consecutive successes).
If Cognito is unavailable: API Gateway cannot validate JWTs and rejects all requests. There is no fallback authentication path — this is an accepted availability tradeoff given Cognito's own high availability SLA.
RDS Write Pressure from AsyncPostgresSaver
If RDS write IOPS sustains above the 3,000 IOPS alarm threshold due to checkpoint volume at high concurrency: CloudWatch triggers the alert, and the team evaluates an instance class upgrade to db.r7g.xlarge. In the interim, checkpoint write latency may increase, which adds latency to each node transition. Sessions remain functional — checkpoint writes are synchronous before node advancement, so high latency means slower sessions rather than data loss or session failure.
GDPR Erasure Failures
The erasure Step Functions state machine retries each step (message content update, S3 deletion, pgvector purge, AsyncPostgresSaver checkpoint row deletion, user record soft-delete) independently with exponential backoff. If a step fails after all retries, the state machine moves to a failure state and publishes an alert to SNS. An on-call engineer is notified to manually complete the failed step. A completion audit log is written only after all steps succeed. Partial erasure does not generate a confirmation to the user.
Input Validation Failures
File size exceeds 50 MB: the pre-signed URL generation call to FastAPI validates the declared file size from the client request and rejects it before any S3 interaction. The client receives a clear error message.
Invalid session configuration (e.g., persona count outside 1–10): rejected at the FastAPI validation layer with a descriptive error; no database write occurs.
Pre-Launch Verification
- Bedrock throttling behavior: verified by running a load test against staging with a reduced Bedrock quota, confirming the backoff and pause behavior.
- ECS Fargate Spot interruption and resume: verified by manually stopping a worker task in staging and confirming the heartbeat monitor detects it, spawns a replacement, and the replacement successfully resumes from the RDS
AsyncPostgresSavercheckpoint. AsyncPostgresSavercheckpoint write throughput: verified by running a 500-session concurrent load test in staging and confirming RDS write IOPS remain below 3,000 at db.r7g.large; if they exceed the threshold, the test itself validates the upgrade trigger.- GDPR erasure workflow: verified in staging by triggering the Step Functions state machine against a test tenant and confirming all data is purged — including
AsyncPostgresSavercheckpoint rows — with an audit log entry. - Redis failover and connection ID recovery: verified by triggering an ElastiCache primary failover in staging and confirming sessions resume push delivery after the 30–60 second failover window, with clients able to fetch missed messages by
turn_sequence. - Worker-direct Management API push: verified by confirming a running worker task can successfully push messages to a connected WebSocket client without any relay Lambda, including stale connection ID cleanup behavior.
- Ingestion DLQ behavior: verified by uploading a deliberately corrupted PDF and confirming the session reaches
ingestion_failedstatus and the user is notified.
Risks and Mitigations
Risk 1: Bedrock Claude throughput quota is the hard ceiling for session concurrency At 500 sessions with 1–10 sequential LLM calls per session active simultaneously, the aggregate token throughput can exceed default Bedrock service quotas. If throttling becomes severe enough that backoff delays push sessions beyond user tolerance, sessions will feel "stuck." Mitigation: request a Bedrock quota increase before the production launch via AWS Service Quotas. Instrument per-session LLM latency in CloudWatch and alert if median turn latency exceeds 30 seconds. Implement a session queue (visible to the user) when concurrency approaches the quota ceiling rather than degrading silently.
Risk 2: RDS write IOPS saturation from AsyncPostgresSaver checkpoint volume
At 500 concurrent sessions each writing a checkpoint on every node boundary, AsyncPostgresSaver generates a sustained high-throughput write workload on the primary RDS instance. If write IOPS saturates the instance class, checkpoint write latency increases, which directly slows node transitions across all active sessions simultaneously — a single-point degradation affecting all users. Mitigation: size the instance at db.r7g.large with gp3 storage and a 3,000 IOPS CloudWatch alarm. Validate checkpoint throughput under 500-session load in staging before production launch. Establish a clear runbook for upgrading to db.r7g.xlarge if the alarm fires. As a longer-term option, evaluate AsyncPostgresSaver's ability to write checkpoints to a read replica — if supported, this offloads checkpoint reads to the replica and reduces primary write amplification.
Risk 3: LangGraph state object grows unbounded for long sessions
A session with many rounds and 10 personas accumulates context in the LangGraph state object. If the full message history is kept in the graph state, the AsyncPostgresSaver checkpoint payload grows with session length, eventually causing slow checkpoint writes or serialization failures. Mitigation: the LangGraph state is designed to hold only the current round's working context — not the full message history. Full message history is stored in RDS and retrieved by the Analyst Agent at the end of the session ordered by turn_sequence. The graph state checkpoint remains bounded in size regardless of session length.
Risk 4: pgvector query performance degrades at scale with large ethnographic corpus As the ethnographic corpus grows (both base corpus and tenant-supplied grounding), ANN query latency for persona grounding retrieval may increase. At 500 concurrent sessions each querying pgvector per agent turn, this could become a bottleneck on the RDS instance. Mitigation: HNSW indexes are created on both vector columns at table creation time. The RDS instance class provides sufficient RAM to keep the HNSW index in memory for typical corpus sizes. If the corpus grows beyond what a single instance can serve, read replicas can be added to distribute the ANN query load — pgvector queries during sessions are read-only.
Risk 5: Multi-tenant data isolation bug in pgvector queries
A coding error that omits the tenant_id or session_id filter from a pgvector ANN query could expose one tenant's product context or grounding data to another tenant's persona agent. At a B2B SaaS level, this would be a critical data breach. Mitigation: RDS row-level security policies enforcing tenant_id filtering are the backstop even when the application filter is missing. Every pgvector query is integration-tested with cross-tenant data present in the test database to confirm isolation. An automated test in the CI pipeline specifically checks that a query using one tenant's credentials cannot retrieve another tenant's rows from either vector table.
Implementation Plan
Implementation Plan
This document outlines the MVP implementation plan for a multi-tenant B2B SaaS platform that orchestrates real-time AI focus groups. It focuses on the managed concurrency of ECS Fargate tasks and the dual-layer vector store in pgvector, providing a clean, declarative boundary suitable for an AI coding agent practicing Compound Engineering.
Environment Setup
What you'll need
Before anyone writes a single line of code, you'll need a handful of accounts and a few configuration steps done inside them. Getting these right upfront means the coding agent can connect to real services from day one instead of hitting walls mid-build.
Accounts to create
- AWS — aws.amazon.com — Everything runs here: your database, containers, file storage, authentication, AI models, and the API itself. This is the one account that powers the whole platform.
- GitHub — github.com — Stores your code and runs the automated build and deploy pipeline every time changes are merged.
- Anthropic (via AWS Bedrock) — No separate Anthropic account needed. Claude is accessed through AWS Bedrock, which you enable inside your AWS account (see below). All billing goes through AWS.
Before you start
Inside your AWS account:
Enable Amazon Bedrock in your chosen region (us-east-1 or eu-west-1). Inside Bedrock, go to Model Access and request access for Claude claude-sonnet-4-5 and Titan Embeddings v2. Approval is usually instant but can take a few minutes.
Request a Bedrock service quota increase for Claude claude-sonnet-4-5 tokens per minute before going to production. File this early — it can take a day or two to process.
Create a Cognito User Pool. Inside it, enable email+password sign-in and TOTP MFA (optional for users but the pool should support it). Note the User Pool ID and the App Client ID — you'll need both.
Create two S3 buckets in your chosen region: one for raw file uploads, one for analyst reports. On both, turn on versioning, enable server-side encryption with AWS KMS, and make sure Block Public Access is fully enabled. Set a bucket lifecycle policy that keeps data in the region you chose.
In Secrets Manager, create secrets for your RDS master password and Redis auth token before the database is created — CDK will reference these by ARN during the first deploy.
In your GitHub repository settings, go to Environments and create two environments:
stagingandproduction. Add a required manual approval reviewer on theproductionenvironment (this is the gate between staging and prod deploys).Create an IAM role for GitHub Actions (using OIDC federation — no long-lived keys). Grant it permissions to push to ECR, deploy CDK stacks, and update ECS services.
Verify a sending domain or email address in Amazon SES. If your account is still in SES sandbox mode, request production access so you can send to any address.
In API Gateway, after the WebSocket API is created by CDK, note the WebSocket stage URL and the Management API endpoint — the orchestration worker needs these at runtime.
For local CDK bootstrapping:
The coding agent will need the AWS CDK bootstrapped in your account and region once (run cdk bootstrap with appropriate credentials). This only needs to happen once per account/region pair.
Environment variables
The coding agent will need these available in the GitHub Actions environment secrets and as ECS task environment variable references:
GitHub Actions secrets (CI/CD pipeline):
AWS_ACCOUNT_ID— Your 12-digit AWS account numberAWS_REGION— The region you chose (e.g.,us-east-1)CDK_DEFAULT_ACCOUNT— Same asAWS_ACCOUNT_IDCDK_DEFAULT_REGION— Same asAWS_REGION
FastAPI control plane task (injected via ECS Secrets integration from Secrets Manager):
DATABASE_URL— Secrets Manager → your RDS secret → assemble aspostgresql+asyncpg://user:password@host:5432/dbnameREDIS_URL— Secrets Manager → your ElastiCache secret →rediss://auth-token@cluster-endpoint:6379COGNITO_USER_POOL_ID— Cognito console → User Pools → your pool → Pool IDCOGNITO_APP_CLIENT_ID— Cognito console → your pool → App clients → Client IDCOGNITO_REGION— Same asAWS_REGIONS3_UPLOADS_BUCKET— The name of the uploads S3 bucket you createdS3_REPORTS_BUCKET— The name of the reports S3 bucket you createdSQS_INGESTION_QUEUE_URL— SQS console → your ingestion queue → URLSQS_INGESTION_DLQ_URL— SQS console → your ingestion dead-letter queue → URLSQS_COMPLETION_QUEUE_URL— SQS console → your ingestion completion notification queue → URLECS_CLUSTER_ARN— ECS console → Clusters → your cluster → ARNWORKER_TASK_DEFINITION_ARN— ECS console → Task Definitions → orchestration worker → latest revision ARNINGESTION_TASK_DEFINITION_ARN— ECS console → Task Definitions → ingestion worker → latest revision ARNWORKER_SUBNET_IDS— VPC console → Subnets → your private subnets (comma-separated IDs)WORKER_SECURITY_GROUP_ID— EC2 console → Security Groups → worker task SG → IDWEBSOCKET_API_ENDPOINT— API Gateway console → your WebSocket API → stage → WebSocket URL (wss://...)MANAGEMENT_API_ENDPOINT— API Gateway console → your WebSocket API → stage → Connection URL (https://.../@connections)AWS_BEDROCK_REGION— Same asAWS_REGIONSES_SENDER_ADDRESS— The verified email address or domain identity in SES
Orchestration worker task (injected via ECS Secrets integration, plus passed at RunTask time):
DATABASE_URL— Same as aboveREDIS_URL— Same as aboveMANAGEMENT_API_ENDPOINT— Same as aboveS3_REPORTS_BUCKET— Same as aboveAWS_BEDROCK_REGION— Same as aboveSESSION_ID— Passed atRunTasklaunch time per session (not a static secret)WORKER_SUBNET_IDS— Same as above
Ingestion worker task:
DATABASE_URL— Same as aboveS3_UPLOADS_BUCKET— Same as aboveSQS_INGESTION_QUEUE_URL— Same as aboveSQS_COMPLETION_QUEUE_URL— Same as aboveAWS_BEDROCK_REGION— Same as above
Phases
Phase 1: Infrastructure Foundation
This phase establishes the complete AWS infrastructure using CDK before any application code is written. Every downstream phase deploys into this foundation, so it must be complete and stable first. It covers networking, data stores, compute cluster definitions, IAM roles, and the CI/CD pipeline skeleton — everything the application needs to exist before it can run.
Contracts
CDK Stack organization
Four CDK stacks, each in its own file under infra/lib/:
NetworkStack (infra/lib/network-stack.ts)
- Creates the VPC with public, private-app, and private-data subnet tiers across two AZs
- Creates VPC endpoints: S3 Gateway endpoint, and Interface endpoints for Bedrock, Secrets Manager, and ECR
- Exports:
vpc,privateAppSubnets(array),privateDataSubnets(array),publicSubnets(array) - Creates security groups:
albSg,fastApiSg,workerSg,ingestionSg,rdsSg,redisSg - Security group rules:
albSg: inbound 443 from internetfastApiSg: inbound fromalbSgonlyworkerSg: no inbound; outbound unrestricted (HTTPS to Management API)ingestionSg: no inboundrdsSg: inbound 5432 fromfastApiSgandworkerSgandingestionSgredisSg: inbound 6379 fromfastApiSgandworkerSg
DataStack (infra/lib/data-stack.ts)
- RDS PostgreSQL 16, Multi-AZ, db.r7g.large, 500 GB gp3 storage with auto-scaling to 2 TB,
AsyncPostgresSaverrequires the pgvector extension enabled in the parameter group - ElastiCache Redis 7.x, cache.r7g.large, Multi-AZ with automatic failover, cluster mode disabled, auth token from Secrets Manager
- S3 uploads bucket and reports bucket (versioning on, KMS encryption, Block Public Access on, event notification to SQS ingestion queue on uploads bucket)
- SQS ingestion queue, SQS ingestion DLQ (4 receives before DLQ), SQS completion queue; ingestion queue message retention 24 hours
- CloudWatch alarm: RDS write IOPS > 3,000 sustained 5 minutes → SNS topic
- Exports:
rdsEndpoint,rdsSecretArn,redisEndpoint,uploadsBucketName,reportsBucketName,ingestionQueueUrl,ingestionDlqUrl,completionQueueUrl
ComputeStack (infra/lib/compute-stack.ts)
- ECS cluster (Container Insights enabled)
- ECR repositories:
fastapi-control-plane,orchestration-worker,ingestion-worker - ALB (internet-facing, HTTPS listener, forwards to FastAPI target group)
- FastAPI ECS Service task definition: 1 vCPU, 2 GB RAM; IAM task role granting SQS send, ECS RunTask, Secrets Manager read, SES send, S3 read reports bucket, Bedrock invoke
- Orchestration worker task definition: 2 vCPU, 4 GB RAM; IAM task role granting RDS access (via
DATABASE_URLsecret), ElastiCache access, API Gateway Management API post, S3 read uploads and write reports, Bedrock invoke, Secrets Manager read; Fargate Spot capacity provider with on-demand fallback - Ingestion worker ECS Service task definition: 1 vCPU, 2 GB RAM; IAM task role granting SQS receive/delete on ingestion queue, SQS send on completion queue, S3 read uploads, Bedrock invoke Titan Embeddings, Secrets Manager read; scales on SQS depth (min 0, max 10 tasks)
- FastAPI service: min 2, max 20 tasks; target tracking on ALB request count per target at 1,000 RPM
- AWS WAF v2 WebACL attached to API Gateway: rate limiting per IP, SQL injection managed rule group, size constraint rule (reject bodies over 50 MB)
- Exports:
ecsClusterArn,fastApiServiceName,workerTaskDefinitionArn,ingestionTaskDefinitionArn,albDnsName
AppStack (infra/lib/app-stack.ts)
- API Gateway REST API (HTTP API v2) with Cognito JWT authorizer
- API Gateway WebSocket API with a Cognito Lambda Authorizer attached to the
$connectroute to validate the JWT and enforce workspace access. Routes include$connect,$disconnect,$default $connectLambda: readssession_idfrom query string, writes connection ID to Redis setws:connections:{session_id}with TTL equal to maximum session duration (7,200 seconds); also stores the Management API endpoint in Redis keyws:endpointon first write$disconnectLambda: removes connection ID from Redis set- Lambda functions for REST CRUD routes (tenant, workspace, session list/get, report retrieval) that are lightweight enough for Lambda; session launch and long-lived operations proxy to the ALB
- Cognito User Pool and App Client (email+password, TOTP MFA support)
- Cognito post-confirmation Lambda trigger (creates User record in RDS, assigns default workspace)
- Amazon Amplify app pointing at the GitHub repository's
frontend/directory - SES email identity configuration
- CloudWatch Log groups (90-day retention) for all services
- X-Ray tracing enabled on FastAPI task definition and API Gateway
- CloudWatch alarms → SNS topic: FastAPI task count < 2, worker task failure rate > 5%, DLQ depth > 0, API Gateway 5xx rate > 1%, ElastiCache node failure, RDS Multi-AZ failover
IAM role naming convention
All task roles follow the pattern {env}-{service}-task-role (e.g., prod-orchestration-worker-task-role). The GitHub Actions OIDC role is github-actions-deploy-role.
GitHub Actions workflow files
infra/lib/network-stack.ts, infra/lib/data-stack.ts, infra/lib/compute-stack.ts, infra/lib/app-stack.ts are the CDK source files.
.github/workflows/pr.yml — on pull request: CDK diff, lint, type-check (no deploy)
.github/workflows/deploy.yml — on merge to main: build and push Docker images to ECR tagged with Git SHA, CDK deploy to staging, integration test placeholder (passes trivially at this phase), manual approval gate, CDK deploy to production
Deliverables:
- All four CDK stacks deploy cleanly to staging with
cdk deploy --all - RDS instance is reachable from within the VPC (verified via a connectivity check from a temporary task)
- ElastiCache Redis is reachable from within the VPC
- Both S3 buckets exist with correct policies
- All three ECR repositories exist and accept image pushes
- GitHub Actions PR workflow runs and produces a CDK diff on a test PR
- GitHub Actions deploy workflow runs and reaches the manual approval gate on merge to
main
Phase 2: Database Schema and Shared Data Layer
This phase creates every database table, index, and constraint the application needs, and establishes the shared Python module that all backend services use to connect to RDS and Redis. Getting the schema fully right before any service code is written prevents migration conflicts between the control plane, worker, and ingestion services built in later phases.
Contracts
Database schema
All tables in a single Alembic migration file at backend/migrations/versions/001_initial_schema.py. The migration runs on FastAPI service startup.
Tables and columns (exact names used by all services):
tenants: id UUID PK, name TEXT NOT NULL, gdpr_region ENUM('eu','us') NOT NULL, erasure_requested_at TIMESTAMPTZ nullable, created_at TIMESTAMPTZ DEFAULT NOW()
workspaces: id UUID PK, tenant_id UUID FK(tenants.id) NOT NULL, name TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW()
users: id UUID PK (Cognito sub), tenant_id UUID FK(tenants.id) NOT NULL, email TEXT NOT NULL, display_name TEXT, created_at TIMESTAMPTZ DEFAULT NOW()
workspace_members: workspace_id UUID FK(workspaces.id), user_id UUID FK(users.id), role ENUM('owner','editor','viewer') NOT NULL, PK(workspace_id, user_id)
sessions: id UUID PK, workspace_id UUID FK(workspaces.id) NOT NULL, tenant_id UUID NOT NULL (denormalized for query efficiency), status ENUM('configuring','ingesting','running','paused','completed','failed','suspended') NOT NULL DEFAULT 'configuring', current_round INT NOT NULL DEFAULT 0, persona_count INT NOT NULL CHECK(persona_count BETWEEN 1 AND 10), topic TEXT NOT NULL, total_rounds INT NOT NULL DEFAULT 5, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW()
persona_configs: id UUID PK, session_id UUID FK(sessions.id) NOT NULL, tenant_id UUID NOT NULL, archetype TEXT NOT NULL, display_name TEXT NOT NULL, grounding_doc_s3_key TEXT nullable, created_at TIMESTAMPTZ DEFAULT NOW()
messages: id UUID PK, session_id UUID FK(sessions.id) NOT NULL, tenant_id UUID NOT NULL, turn_sequence INT NOT NULL, agent_role ENUM('moderator','persona','analyst') NOT NULL, persona_config_id UUID FK(persona_configs.id) nullable, content TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(session_id, turn_sequence)
analyst_reports: id UUID PK, session_id UUID FK(sessions.id) UNIQUE NOT NULL, tenant_id UUID NOT NULL, s3_key TEXT NOT NULL, summary TEXT, created_at TIMESTAMPTZ DEFAULT NOW()
ethnographic_chunks: id UUID PK, tenant_id UUID nullable, corpus TEXT NOT NULL DEFAULT 'base', source_doc_s3_key TEXT, content TEXT NOT NULL, embedding vector(1536) NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW()
product_context_chunks: id UUID PK, session_id UUID FK(sessions.id) NOT NULL, tenant_id UUID NOT NULL, content TEXT NOT NULL, embedding vector(1536) NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW()
gdpr_audit_log: id UUID PK, tenant_id UUID NOT NULL, action TEXT NOT NULL, completed_at TIMESTAMPTZ DEFAULT NOW(), details JSONB
Indexes:
- HNSW index on
ethnographic_chunks.embeddingwithvector_cosine_ops - HNSW index on
product_context_chunks.embeddingwithvector_cosine_ops - Index on
messages(session_id, turn_sequence) - Index on
sessions(tenant_id, status) - Index on
product_context_chunks(session_id, tenant_id) - Index on
ethnographic_chunks(tenant_id)(partial: WHERE tenant_id IS NOT NULL)
RDS Row-Level Security policies: Enabled on sessions, messages, persona_configs, analyst_reports, product_context_chunks, workspace_members, workspaces, users. Each policy enforces tenant_id = current_setting('app.current_tenant_id')::uuid. The application sets this session variable before every query.
AsyncPostgresSaver tables: Created automatically by LangGraph on first worker startup. The migration does not create these — the worker creates them. The migration does create a langgraph_thread_tenant_map table: thread_id TEXT PK, tenant_id UUID NOT NULL, session_id UUID FK(sessions.id) NOT NULL — used exclusively by the GDPR erasure workflow to identify which checkpoint rows to delete.
Shared Python modules
All shared modules live in backend/shared/. Both the FastAPI control plane and the orchestration worker import from this package.
backend/shared/db.py
# Module-level engine and sessionmaker; call init_db() once at service startup
async def init_db(database_url: str) -> None:
# Creates async SQLAlchemy engine and sessionmaker, stores as module globals
# Enables pgvector extension registration on the engine
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
# Yields an AsyncSession; sets app.current_tenant_id session variable before yielding
# Caller must set tenant_id on the session via set_tenant_context()
async def set_tenant_context(session: AsyncSession, tenant_id: str) -> None:
# Executes: SET LOCAL app.current_tenant_id = '{tenant_id}'
# Must be called at the start of every request handler before any query
async def close_db() -> None:
# Disposes the engine; called on service shutdown
backend/shared/redis_client.py
async def init_redis(redis_url: str) -> None:
# Creates an aioredis connection pool; stores as module global
async def get_redis() -> Redis:
# Returns the module-global Redis client
async def close_redis() -> None:
# Closes the Redis connection pool
# Redis key conventions (used by all services — must match exactly):
WS_CONNECTIONS_KEY = "ws:connections:{session_id}" # Redis Set; members are connection IDs
HEARTBEAT_KEY = "ws:heartbeat:{session_id}" # Redis String; value is ISO timestamp
MANAGEMENT_ENDPOINT_KEY = "ws:endpoint" # Redis String; API Gateway Management API URL
backend/shared/models.py
SQLAlchemy ORM models matching the schema above exactly. Every model includes a tenant_id column. Model class names: Tenant, Workspace, User, WorkspaceMember, Session, PersonaConfig, Message, AnalystReport, EthnographicChunk, ProductContextChunk, GdprAuditLog, LangGraphThreadTenantMap.
backend/shared/schemas.py
Pydantic v2 models for API request/response shapes. Key schemas:
class SessionStatus(str, Enum):
configuring = "configuring"
ingesting = "ingesting"
running = "running"
paused = "paused"
completed = "completed"
failed = "failed"
suspended = "suspended"
class AgentRole(str, Enum):
moderator = "moderator"
persona = "persona"
analyst = "analyst"
class PersonaConfigCreate(BaseModel):
archetype: str # max 200 chars; control characters stripped
display_name: str # max 100 chars
class SessionCreate(BaseModel):
workspace_id: UUID
topic: str # max 1000 chars; control characters stripped
persona_count: int # validated 1–10
total_rounds: int # validated 1–20; default 5
personas: list[PersonaConfigCreate] # length must equal persona_count
class MessageOut(BaseModel):
id: UUID
session_id: UUID
turn_sequence: int
agent_role: AgentRole
persona_config_id: UUID | None
content: str
created_at: datetime
class SessionOut(BaseModel):
id: UUID
workspace_id: UUID
status: SessionStatus
current_round: int
persona_count: int
topic: str
total_rounds: int
created_at: datetime
updated_at: datetime
class WorkspaceMessagePush(BaseModel):
# Shape pushed to WebSocket clients by the orchestration worker
event: str # "agent_message" | "session_paused" | "session_suspended" | "session_completed" | "session_resuming"
session_id: str
message: MessageOut | None # populated for "agent_message" events
reason: str | None # populated for pause/error events
backend/shared/errors.py
class AppError(Exception):
def __init__(self, message: str, correlation_id: str): ...
class AuthorizationError(AppError): ... # tenant or workspace access denied
class ValidationError(AppError): ... # input validation failure
class SessionStateError(AppError): ... # invalid session status transition
class IngestionError(AppError): ... # document parsing or embedding failure
class CheckpointError(AppError): ... # AsyncPostgresSaver write failure
class LLMError(AppError): ... # Bedrock call failure after retries exhausted
class CapacityError(AppError): ... # 500-session limit reached
backend/shared/sanitize.py
def sanitize_user_text(text: str, max_length: int) -> str:
# Strips control characters (Unicode categories Cc and Cf, except tab and newline)
# Truncates to max_length
# Returns sanitized string
Deliverables:
- Alembic migration runs cleanly against a fresh RDS instance and creates all tables, indexes, and RLS policies
- RLS policies verified: a query with one tenant's ID cannot return rows owned by another tenant
- HNSW indexes created on both vector columns
- All shared Python modules importable with no circular dependencies
AsyncPostgresSavertables created by a test worker startup and confirmed present in RDS
Phase 3: Authentication and Tenant Management
This phase delivers the Cognito integration, the JWT validation middleware, and the full tenant/workspace/user management API surface. Every subsequent phase depends on authenticated, tenant-scoped requests working correctly. No other business logic is built until this layer is solid.
Contracts
FastAPI application entry point
backend/control_plane/main.py
# Lifespan context manager calls: init_db(), init_redis(), run_migrations()
# Mounts routers: auth_router, tenant_router, workspace_router, user_router
# Global exception handler: catches AppError subclasses, returns JSON with correlation_id and generic message; logs full details to CloudWatch
# X-Ray middleware enabled
# CORS: allow only the Amplify frontend origin (from env ALLOWED_ORIGIN)
# Security response headers middleware: HSTS, X-Content-Type-Options, X-Frame-Options, CSP
JWT middleware
backend/control_plane/auth/middleware.py
async def verify_cognito_token(
authorization: str, # "Bearer <token>" from Authorization header
cognito_user_pool_id: str,
cognito_region: str,
) -> TokenClaims:
# Fetches Cognito JWKS (cached with 1-hour TTL)
# Validates JWT signature, expiry, issuer, and audience
# Returns TokenClaims on success; raises AuthorizationError on failure
@dataclass
class TokenClaims:
user_id: str # Cognito sub
tenant_id: str # custom:tenant_id claim
email: str
workspace_roles: dict[str, str] # workspace_id → role, from custom claims
FastAPI dependency get_current_user extracts and validates the token, then calls set_tenant_context(session, claims.tenant_id). Every route that touches the database uses this dependency.
Workspace authorization
backend/control_plane/auth/authorization.py
async def require_workspace_role(
workspace_id: UUID,
user_id: str,
minimum_role: Literal["viewer", "editor", "owner"],
db: AsyncSession,
) -> None:
# Queries WorkspaceMember for (workspace_id, user_id)
# Raises AuthorizationError if the member doesn't exist or role is below minimum
# Role order: viewer < editor < owner
Tenant router
backend/control_plane/routers/tenants.py
POST /tenants
body: TenantCreate(name: str, gdpr_region: Literal['eu', 'us'])
returns: TenantOut(id, name, gdpr_region, created_at)
# Creates Tenant row and a default Workspace; assigns the calling user as workspace owner
# Only callable before the user has a tenant assigned (bootstrap flow)
GET /tenants/me
returns: TenantOut
# Returns the tenant for the calling user's tenant_id
Workspace router
backend/control_plane/routers/workspaces.py
GET /workspaces
returns: list[WorkspaceOut(id, name, tenant_id, created_at)]
# All workspaces for the calling user's tenant
POST /workspaces
body: WorkspaceCreate(name: str)
returns: WorkspaceOut
# Caller becomes owner
GET /workspaces/{workspace_id}
returns: WorkspaceOut
# Requires viewer role or above
POST /workspaces/{workspace_id}/members
body: MemberInvite(email: str, role: Literal['editor', 'viewer'])
returns: MemberOut(workspace_id, user_id, role)
# Requires owner role; sends SES invitation email; creates WorkspaceMember row
# If the invited user doesn't exist in RDS yet, creates a pending record
DELETE /workspaces/{workspace_id}/members/{user_id}
returns: 204
# Requires owner role
User router
backend/control_plane/routers/users.py
GET /users/me
returns: UserOut(id, tenant_id, email, display_name, created_at)
PATCH /users/me
body: UserUpdate(display_name: str | None)
returns: UserOut
Cognito post-confirmation Lambda
backend/lambdas/cognito_post_confirmation/handler.py
def handler(event: dict, context) -> dict:
# Triggered by Cognito after email verification
# Reads user attributes from event: sub, email, custom:tenant_id (may be absent on first user)
# If tenant_id absent: creates a new Tenant (gdpr_region from event custom:gdpr_region attribute)
# Creates User row in RDS with id = sub, email, tenant_id
# Creates default Workspace "Default Workspace" and assigns user as owner
# Returns event unchanged (Cognito trigger requirement)
# Uses a direct psycopg2 connection (not the async SQLAlchemy engine) for simplicity in Lambda context
SES email helper
backend/shared/email.py
async def send_workspace_invitation(
to_address: str,
workspace_name: str,
inviter_name: str,
role: str,
) -> None:
# Calls SES SendEmail; on SES unavailability logs the failure and enqueues to DLQ — does not raise
async def send_session_completion_notification(
to_address: str,
session_id: str,
workspace_name: str,
) -> None:
# Same non-blocking behavior
async def send_gdpr_erasure_confirmation(to_address: str) -> None:
# Same non-blocking behavior
Deliverables:
POST /tenantscreates a tenant, workspace, and owner membership; returns correctTenantOutGET /workspacesreturns only workspaces belonging to the calling user's tenant (cross-tenant isolation verified with two test tenants)- An expired or malformed JWT returns 401 with a correlation ID and no stack trace
- Workspace invitation sends a real SES email in staging (SES sandbox: verified addresses only)
- Cognito post-confirmation Lambda creates User and Workspace records in RDS on first sign-up
Phase 4: Document Ingestion Pipeline
This phase delivers the full pipeline from file upload through chunking, embedding, and vector storage. It also establishes the error handling contract for all Bedrock calls, which later phases inherit. A session cannot move from configuring to running until ingestion completes successfully.
Contracts
Pre-signed URL endpoint
backend/control_plane/routers/uploads.py
POST /sessions/{session_id}/upload
body: UploadRequest(filename: str, content_type: str, file_size_bytes: int)
returns: UploadResponse(presigned_url: str, s3_key: str, upload_id: str)
# Requires editor role on the session's workspace
# Validates content_type is one of: application/pdf, application/vnd.openxmlformats-officedocument.presentationml.presentation, application/vnd.ms-powerpoint, application/vnd.apple.keynote
# Validates file_size_bytes ≤ 52_428_800 (50 MB); raises ValidationError if exceeded
# S3 key: uploads/{tenant_id}/{session_id}/{upload_id}/{filename}
# Pre-signed URL expires in 900 seconds
# Updates session status to 'ingesting'
POST /sessions/{session_id}/upload/{upload_id}/complete
returns: 204
# Called by frontend after direct S3 upload succeeds
# Publishes SQS message to ingestion queue with IngestionJob payload
# Session status remains 'ingesting' until ingestion worker reports completion
SQS message shapes
@dataclass
class IngestionJob:
job_id: str # UUID, unique per upload
session_id: str
tenant_id: str
s3_key: str
filename: str
content_type: str
@dataclass
class IngestionComplete:
job_id: str
session_id: str
tenant_id: str
status: Literal['success', 'failed']
error: str | None
chunk_count: int | None
Ingestion worker
backend/ingestion_worker/main.py — entry point; polls SQS ingestion queue, dispatches to process_job()
backend/ingestion_worker/processor.py
async def process_job(job: IngestionJob) -> IngestionComplete:
# 1. Fetch file from S3 into memory (raises IngestionError if file not found)
# 2. Parse file: route to parse_pdf() or parse_pptx() based on content_type
# 3. Chunk the extracted text into ~512-token segments with 10% overlap
# 4. Generate embeddings for each chunk via embed_chunks()
# 5. Upsert ProductContextChunk rows into pgvector (session_id + tenant_id tagged)
# 6. Return IngestionComplete(status='success', chunk_count=N)
# On IngestionError at any step: return IngestionComplete(status='failed', error=str(e))
backend/ingestion_worker/parsers.py
def parse_pdf(file_bytes: bytes) -> list[str]:
# Uses PyMuPDF to extract text page by page
# Returns list of page text strings
# Raises IngestionError if the file cannot be opened or yields no text
def parse_pptx(file_bytes: bytes) -> list[str]:
# Uses python-pptx to extract text from each slide's text frames
# Returns list of slide text strings
# Raises IngestionError if the file cannot be opened or yields no text
backend/ingestion_worker/chunker.py
def chunk_text(pages: list[str], target_tokens: int = 512, overlap_fraction: float = 0.10) -> list[str]:
# Concatenates pages with paragraph separators
# Splits into chunks of ~target_tokens tokens (approximated as chars / 4)
# Overlap: last overlap_fraction * target_tokens tokens of each chunk prepended to next
# Returns list of chunk strings; minimum 1 chunk even for short documents
backend/ingestion_worker/embedder.py
async def embed_chunks(chunks: list[str], bedrock_region: str) -> list[list[float]]:
# Calls Bedrock Titan Embeddings v2 for each chunk sequentially
# Returns list of 1536-dimension float vectors in the same order as input chunks
# Error handling (first introduced here — all subsequent Bedrock embedding calls inherit this contract):
# - On transient Bedrock service error: retry up to 5 times with increasing delay (30s, 60s, 120s, 300s, 600s)
# - After 5 failures: raises IngestionError with the original error message
# - On non-retryable error (e.g., invalid input): raises IngestionError immediately without retry
SQS error handling and DLQ behavior
The ingestion worker SQS consumer:
- On
IngestionErrorreturned fromprocess_job(): does not delete the SQS message; returns the message to the queue (message becomes visible again after the visibility timeout). The SQS queue'smaxReceiveCountis 5; after 5 receives the message moves to the DLQ automatically. - On
IngestionComplete(status='failed')returned after all retries insideprocess_job(): publishesIngestionCompleteto the completion queue, deletes the SQS message (the job is done — it just failed), updates session status toingestion_failedin RDS. - On
IngestionComplete(status='success'): publishesIngestionCompleteto the completion queue, deletes the SQS message, sendssession_completionnotification via SES.
Completion queue consumer
backend/control_plane/routers/ingestion_events.py — a background task started by the FastAPI lifespan that polls the SQS completion queue and updates session status accordingly:
status='success': session status →configuring(ready to launch),updated_atrefreshedstatus='failed': session status →ingestion_failed; SES notification sent to session owner
Grounding doc ingestion
Same pipeline as above, except:
- S3 key:
uploads/{tenant_id}/grounding/{persona_config_id}/{filename} - Chunks are inserted into
ethnographic_chunks(notproduct_context_chunks), withtenant_idset andcorpus = 'user' - Triggered via
POST /personas/{persona_config_id}/grounding-doc/upload(same pre-signed URL pattern)
Deliverables:
- Uploading a valid PDF via the pre-signed URL flow results in
ProductContextChunkrows in pgvector with correctsession_idandtenant_id - Uploading a corrupted PDF results in session status
ingestion_failedand an SES email sent (to a verified address in staging) - A Bedrock Titan unavailability simulation (via mocking) triggers the retry sequence and ultimately DLQ placement
EthnographicChunkrows inserted for a grounding doc are tagged withtenant_idandcorpus = 'user'- Cross-tenant query isolation verified: a pgvector ANN query with one tenant's
tenant_iddoes not return another tenant'sproduct_context_chunks
Phase 5: Session Management and Worker Lifecycle
This phase delivers the session CRUD API, the persona configuration endpoints, the ECS RunTask integration that spawns orchestration workers, and the heartbeat monitor that detects and recovers crashed workers. This is the control plane's most critical operational responsibility.
Contracts
Session router
backend/control_plane/routers/sessions.py
POST /workspaces/{workspace_id}/sessions
body: SessionCreate
returns: SessionOut
# Requires editor role
# Validates persona_count matches len(personas)
# Creates Session (status='configuring') and PersonaConfig rows in one transaction
# Returns SessionOut
GET /workspaces/{workspace_id}/sessions
returns: list[SessionOut]
# Requires viewer role
# Returns sessions ordered by created_at DESC
GET /sessions/{session_id}
returns: SessionOut
# Requires viewer role on the session's workspace (resolved via session→workspace join)
DELETE /sessions/{session_id}
returns: 204
# Requires owner role
# Soft-deletes: sets status='failed', does not delete rows
# If session has an active worker task: calls ECS StopTask before marking failed
# Purges ProductContextChunk rows for the session from pgvector
POST /sessions/{session_id}/launch
returns: SessionOut (status='running')
# Requires editor role
# Valid only when session status is 'configuring' (raises SessionStateError otherwise)
# Enforces the 500-session concurrency cap: counts active sessions (status IN ('running','paused','suspended')) across all tenants; raises CapacityError if ≥ 500
# Calls spawn_worker_task(); on failure retries 3 times with increasing delay; after 3 failures sets session status='failed' and raises AppError
# On success: sets session status='running', records worker ECS task ARN in a new column sessions.worker_task_arn TEXT nullable
POST /sessions/{session_id}/resume
returns: SessionOut
# Requires editor role
# Valid when status is 'paused' or 'suspended'
# Calls spawn_worker_task() with resume=True; same retry and failure behavior as launch
GET /sessions/{session_id}/messages
query params: after_sequence: int | None (default None — returns all messages)
returns: list[MessageOut] ordered by turn_sequence ASC
# Requires viewer role
# If after_sequence provided: returns messages WHERE turn_sequence > after_sequence
# Used by frontend for transcript replay and missed-message recovery
Worker lifecycle module
backend/control_plane/worker_lifecycle.py
async def spawn_worker_task(
session_id: str,
tenant_id: str,
resume: bool,
ecs_client,
cluster_arn: str,
task_definition_arn: str,
subnet_ids: list[str],
security_group_id: str,
) -> str:
# Calls ECS RunTask with:
# - cluster: cluster_arn
# - task definition: task_definition_arn
# - launch type: FARGATE (capacity provider: Spot with on-demand fallback)
# - network config: subnet_ids, security_group_id
# - environment overrides: SESSION_ID={session_id}, TENANT_ID={tenant_id}, RESUME={resume}
# Returns the task ARN on success
# Raises AppError on ECS API failure (after 3 retries with increasing delay)
async def stop_worker_task(task_arn: str, ecs_client, cluster_arn: str) -> None:
# Calls ECS StopTask; ignores "task not found" errors (idempotent)
# Raises AppError on other ECS API failures
Heartbeat monitor
backend/control_plane/heartbeat_monitor.py
async def run_heartbeat_monitor(
poll_interval_seconds: int = 30,
missed_threshold: int = 3,
) -> None:
# Background asyncio task started by FastAPI lifespan
# Every poll_interval_seconds:
# 1. Attempt to acquire Redis distributed lock: SET ws:lock:heartbeat_monitor true NX EX 25
# 2. If lock acquired, query RDS for sessions with status IN ('running', 'suspended') and worker_task_arn IS NOT NULL
# 3. For each: read HEARTBEAT_KEY from Redis
# 4. If key missing or last heartbeat older than (missed_threshold × poll_interval_seconds) seconds:
# a. Set session status = 'suspended' in RDS
# b. Call spawn_worker_task(resume=True) for the session
# c. Update sessions.worker_task_arn with the new task ARN
# d. Push WorkspaceMessagePush(event='session_suspended') to all session WebSocket connections via Management API
# 5. Log all state transitions with session_id and correlation_id
async def push_to_session_connections(
session_id: str,
payload: WorkspaceMessagePush,
redis,
management_api_endpoint: str,
apigw_management_client,
) -> None:
# Reads Redis set WS_CONNECTIONS_KEY for the session
# For each connection ID: posts payload JSON to Management API
# On "gone" response: removes connection ID from Redis set
# On other errors: logs with correlation_id and continues (does not raise)
# This function is defined here and reused by the orchestration worker (same contract)
Valid session status transitions
Enforced in backend/control_plane/session_state.py:
VALID_TRANSITIONS: dict[SessionStatus, set[SessionStatus]] = {
SessionStatus.configuring: {SessionStatus.ingesting, SessionStatus.running, SessionStatus.failed},
SessionStatus.ingesting: {SessionStatus.configuring, SessionStatus.ingestion_failed, SessionStatus.failed},
SessionStatus.running: {SessionStatus.paused, SessionStatus.suspended, SessionStatus.completed, SessionStatus.failed},
SessionStatus.paused: {SessionStatus.running, SessionStatus.failed},
SessionStatus.suspended: {SessionStatus.running, SessionStatus.failed},
SessionStatus.completed: set(),
SessionStatus.failed: set(),
}
def validate_transition(current: SessionStatus, next: SessionStatus) -> None:
# Raises SessionStateError if the transition is not in VALID_TRANSITIONS[current]
Deliverables:
POST /sessions/{session_id}/launchwith a valid session inconfiguringstatus spawns an ECS Fargate task and sets status torunningPOST /sessions/{session_id}/launchwhen 500 sessions are active returns aCapacityErrorresponse with a correlation ID- An invalid status transition (e.g., launching a
completedsession) returnsSessionStateError - Manually stopping a worker ECS task in staging causes the heartbeat monitor to detect the missed heartbeats, mark the session
suspended, and spawn a replacement task within 90 seconds GET /sessions/{session_id}/messages?after_sequence=5returns only messages withturn_sequence > 5
Phase 6: LangGraph Orchestration Worker
This phase implements the orchestration worker — the most complex component in the system. It delivers the full LangGraph graph with Moderator, Persona, and Analyst agents; RAG retrieval from both pgvector namespaces; checkpoint persistence via AsyncPostgresSaver; real-time WebSocket push; and the heartbeat publisher. This phase has no external API surface — its output is message rows in RDS, checkpoint rows in RDS, and WebSocket pushes to clients.
Contracts
Worker entry point
backend/orchestration_worker/main.py
async def main() -> None:
# Reads SESSION_ID, TENANT_ID, RESUME from environment variables
# Calls init_db(), init_redis()
# Creates AsyncPostgresSaver connection to RDS
# Calls run_session_graph(session_id, tenant_id, resume)
# On unhandled exception: logs with correlation_id, pushes WorkspaceMessagePush(event='session_suspended') to clients, exits non-zero
LangGraph state
backend/orchestration_worker/graph_state.py
class FocusGroupState(TypedDict):
session_id: str
tenant_id: str
topic: str
total_rounds: int
current_round: int # incremented by Moderator at round start
turn_sequence: int # global counter; incremented before each agent turn
persona_configs: list[dict] # list of {id, archetype, display_name}
personas_spoken_this_round: list[str] # persona_config_id strings
current_persona_id: str | None # set by Moderator routing decision
moderator_message: str | None # Moderator's facilitation text for the current turn
last_agent_role: AgentRole | None
session_complete: bool # set True by Moderator when all rounds done
The graph state holds only current-round working context. It does not accumulate message history. Full history is retrieved from RDS by the Analyst Agent at the end.
Graph definition
backend/orchestration_worker/graph.py
def build_graph(checkpointer: AsyncPostgresSaver) -> CompiledGraph:
# Builds a StateGraph[FocusGroupState] with nodes:
# - "moderator": calls moderator_node()
# - "persona": calls persona_node()
# - "analyst": calls analyst_node()
# - "end": terminal node
# Edges:
# - Entry: "moderator"
# - Conditional edge from "moderator": if session_complete → "analyst", elif current_persona_id set → "persona"
# - Edge from "persona" → "moderator"
# - Edge from "analyst" → "end"
# Compiled with checkpointer=checkpointer
# Returns compiled graph
async def run_session_graph(
session_id: str,
tenant_id: str,
resume: bool,
checkpointer: AsyncPostgresSaver,
) -> None:
# thread_config = {"configurable": {"thread_id": session_id}}
# If not resume: initializes FocusGroupState from session and persona_config rows in RDS
# Inserts a LangGraphThreadTenantMap row (thread_id=session_id, tenant_id=tenant_id, session_id=session_id)
# Calls graph.astream(initial_state or None, config=thread_config) — LangGraph resumes from checkpoint if resume=True
# Publishes heartbeat every 30 seconds during graph execution (asyncio background task)
# On graph completion (reaches "end"): sets session status='completed' in RDS
Moderator node
backend/orchestration_worker/agents/moderator.py
async def moderator_node(state: FocusGroupState, config: RunnableConfig) -> FocusGroupState:
# Dependencies (db, bedrock) are resolved from backend.shared globals or config["configurable"]
# 1. Increment turn_sequence by 1
# 2. If all personas have spoken this round and current_round < total_rounds:
# increment current_round, reset personas_spoken_this_round
# If current_round >= total_rounds:
# set session_complete=True, return state (routes to analyst)
# 3. Select the next persona: the first persona_config not in personas_spoken_this_round
# 4. Call Claude via Bedrock to generate facilitation text:
# System prompt: Moderator role description with topic, current round, total rounds
# User prompt: "Generate a focused question or prompt for {persona.display_name} ({persona.archetype}) given the current debate state."
# Response: moderator_message string
# 5. Write moderator_message to RDS messages table (agent_role='moderator', turn_sequence=state.turn_sequence, persona_config_id=None)
# 6. Push WorkspaceMessagePush(event='agent_message', message=MessageOut(...)) to clients
# 7. Return updated state with current_persona_id, moderator_message, turn_sequence, current_round
# LLM error handling (first introduced here for LLM agent calls — persona and analyst nodes inherit this contract):
# - On transient Bedrock throttling: retry up to 5 times with increasing delay starting at 2 seconds, up to 60 seconds
# - If Bedrock is unresponsive for more than 60 seconds total: pause session (status='paused'), commit checkpoint, push WorkspaceMessagePush(event='session_paused'), raise LLMError
# - On CheckpointError from AsyncPostgresSaver: retry up to 3 times with increasing delay; on all failures: pause session, push event, raise CheckpointError
Persona node
backend/orchestration_worker/agents/persona.py
async def persona_node(state: FocusGroupState, config: RunnableConfig) -> FocusGroupState:
# Dependencies (db, bedrock, redis) are resolved from backend.shared globals or config["configurable"]
# 1. Load PersonaConfig for state.current_persona_id from RDS
# 2. Retrieve top-5 ethnographic chunks for the persona's archetype from pgvector:
# ANN query on ethnographic_chunks WHERE (tenant_id = state.tenant_id OR tenant_id IS NULL)
# ordered by cosine similarity to embed(persona.archetype)
# 3. Retrieve top-5 product context chunks for the current topic from pgvector:
# ANN query on product_context_chunks WHERE session_id = state.session_id AND tenant_id = state.tenant_id
# ordered by cosine similarity to embed(state.moderator_message)
# 4. Increment turn_sequence by 1
# 5. Assemble system prompt:
# [ROLE] You are {persona.display_name}, a {persona.archetype}.
# [GROUNDING] {ethnographic chunks concatenated}
# [PRODUCT CONTEXT] {product context chunks concatenated}
# [DEBATE CONTEXT] {state.moderator_message}
# [INSTRUCTION] Respond in character. Max 300 words.
# 6. Call Claude via Bedrock (same LLM error handling contract as moderator_node)
# 7. Sanitize response via sanitize_user_text() — strip control chars, max 2000 chars
# 8. Write persona response to RDS messages table (agent_role='persona', persona_config_id=current_persona_id, turn_sequence=state.turn_sequence)
# 9. Push WorkspaceMessagePush(event='agent_message') to clients
# 10. Add current_persona_id to personas_spoken_this_round; clear current_persona_id
# 11. Return updated state
async def embed_query(text: str, bedrock_client) -> list[float]:
# Calls Bedrock Titan Embeddings v2 for a single string
# Returns 1536-dimension float vector
# Same error handling contract as Phase 4's embed_chunks (ingestion worker)
Analyst node
backend/orchestration_worker/agents/analyst.py
async def analyst_node(state: FocusGroupState, config: RunnableConfig) -> FocusGroupState:
# Dependencies (db, bedrock, s3_client) are resolved from backend.shared globals or config["configurable"]
# 1. Fetch all messages for state.session_id from RDS ordered by turn_sequence ASC
# 2. Format messages as a transcript string (role: content, one per line)
# 3. Call Claude via Bedrock with the full transcript:
# System prompt: Analyst role; instructs generation of a structured JSON report
# Report schema:
# {
# "positioning_findings": str,
# "pricing_sensitivity": str,
# "feature_prioritization": list[str],
# "dissenting_views": list[{"persona": str, "view": str}],
# "summary": str
# }
# 4. Parse Claude's response as JSON (on parse failure: retry the Claude call once; on second failure: store raw text under "raw_output" key)
# 5. Write the JSON blob to S3 at reports/{tenant_id}/{session_id}/report.json
# 6. Write AnalystReport row to RDS with s3_key and summary (report["summary"])
# 7. Set session status='completed' in RDS
# 8. Push WorkspaceMessagePush(event='session_completed') to clients
# 9. Send SES session completion notification to workspace owner
# 10. Return state (session_complete already True)
# Same LLM error handling contract as moderator_node
Heartbeat publisher
backend/orchestration_worker/heartbeat.py
async def publish_heartbeat(session_id: str, redis, interval_seconds: int = 30) -> None:
# Asyncio background task; loops until cancelled
# Every interval_seconds: writes current UTC ISO timestamp to HEARTBEAT_KEY in Redis with TTL of interval_seconds × 5
RAG retrieval module
backend/orchestration_worker/retrieval.py
async def retrieve_ethnographic_chunks(
archetype_embedding: list[float],
tenant_id: str,
top_k: int,
db: AsyncSession,
) -> list[str]:
# ANN query: SELECT content FROM ethnographic_chunks
# WHERE (tenant_id = :tenant_id OR tenant_id IS NULL)
# ORDER BY embedding <=> :query_vector LIMIT :top_k
# Returns list of content strings
async def retrieve_product_context_chunks(
query_embedding: list[float],
session_id: str,
tenant_id: str,
top_k: int,
db: AsyncSession,
) -> list[str]:
# ANN query: SELECT content FROM product_context_chunks
# WHERE session_id = :session_id AND tenant_id = :tenant_id
# ORDER BY embedding <=> :query_vector LIMIT :top_k
# Returns list of content strings
Deliverables:
- A fully configured session with a 2-persona, 2-round configuration runs to completion in staging: all messages written to RDS with correct
turn_sequencevalues, analyst report written to S3 and indexed in RDS, session status set tocompleted - Manually stopping the ECS task mid-session and re-launching it results in the graph resuming from the last committed checkpoint, with
turn_sequencecontinuing from where it left off - WebSocket clients connected during the session receive all agent messages in order
- A simulated Bedrock outage lasting >60 seconds causes the session to pause; the user receives a
session_pausedWebSocket event; the session is resumable - Cross-tenant pgvector retrieval isolation verified: a persona agent for Tenant A does not retrieve
product_context_chunksbelonging to Tenant B's session
Phase 7: Frontend Application
This phase delivers the complete Next.js frontend — workspace dashboard, session configuration wizard, live debate view, and session history/report viewer. All data comes from the REST API and WebSocket API built in earlier phases. The frontend makes no direct calls to AWS services except the pre-signed S3 upload.
Contracts
Application structure
frontend/
app/
(auth)/
login/page.tsx
signup/page.tsx
(app)/
layout.tsx # auth guard; redirects to /login if no valid token
dashboard/page.tsx # workspace overview (RSC)
sessions/
new/page.tsx # session configuration wizard (client component)
[sessionId]/
page.tsx # live debate view (client component)
report/page.tsx # analyst report viewer (RSC)
settings/
workspace/page.tsx # member management
profile/page.tsx
components/
debate/
TranscriptPanel.tsx # renders message list; scrolls to bottom on new message
AgentMessageCard.tsx # single message bubble; styled by agent_role
ConnectionStatus.tsx # WebSocket connection state indicator
session/
ConfigWizard.tsx # multi-step form: topic → personas → upload → launch
PersonaCard.tsx
FileUploader.tsx # handles pre-signed URL upload flow
workspace/
MemberTable.tsx
InviteMemberModal.tsx
shared/
ErrorBoundary.tsx
LoadingSpinner.tsx
CorrelationErrorDisplay.tsx # shows correlation_id to user on API errors
lib/
api-client.ts
websocket-client.ts
auth.ts
hooks/
useSession.ts
useWebSocket.ts
useTranscript.ts
API client
frontend/lib/api-client.ts
// Base client that attaches the Cognito ID token to every request
// All methods return typed responses; on API error, extracts correlation_id and throws ApiError
interface ApiError {
message: string;
correlationId: string;
}
// Methods (all async, all return typed shapes matching backend schemas):
createSession(workspaceId: string, body: SessionCreate): Promise<SessionOut>
launchSession(sessionId: string): Promise<SessionOut>
resumeSession(sessionId: string): Promise<SessionOut>
getSession(sessionId: string): Promise<SessionOut>
listSessions(workspaceId: string): Promise<SessionOut[]>
getMessages(sessionId: string, afterSequence?: number): Promise<MessageOut[]>
requestUploadUrl(sessionId: string, body: UploadRequest): Promise<UploadResponse>
confirmUpload(sessionId: string, uploadId: string): Promise<void>
uploadFileToS3(presignedUrl: string, file: File): Promise<void>
getReport(sessionId: string): Promise<AnalystReportOut>
listWorkspaceMembers(workspaceId: string): Promise<MemberOut[]>
inviteMember(workspaceId: string, body: MemberInvite): Promise<MemberOut>
removeMember(workspaceId: string, userId: string): Promise<void>
WebSocket client
frontend/lib/websocket-client.ts
class FocusGroupWebSocket {
constructor(sessionId: string, wsEndpoint: string, onMessage: (msg: WorkspaceMessagePush) => void)
connect(): void
// Opens WebSocket to wsEndpoint?session_id={sessionId} with Cognito token in query string
// On open: sets status to 'connected'
// On message: parses JSON as WorkspaceMessagePush, calls onMessage
// On close or error: sets status to 'disconnected'; retries up to 5 times with increasing delay
// After 5 failed retries: sets status to 'failed'; stops retrying
disconnect(): void
// Closes WebSocket cleanly
status: 'connecting' | 'connected' | 'disconnected' | 'failed'
}
Live debate hook
frontend/hooks/useTranscript.ts
function useTranscript(sessionId: string): {
messages: MessageOut[];
connectionStatus: string;
sessionStatus: SessionStatus;
pauseReason: string | null;
}
// On mount:
// 1. Fetches all existing messages via getMessages(sessionId) ordered by turn_sequence
// 2. Opens FocusGroupWebSocket
// 3. On 'agent_message' events: appends message to local state if turn_sequence > last seen
// 4. On 'session_paused' / 'session_suspended' / 'session_completed': updates sessionStatus and pauseReason
// 5. On WebSocket reconnect: fetches messages with afterSequence=last_seen_turn_sequence to fill the gap
// Deduplicates by turn_sequence (a message already in state is never added twice)
Session config wizard
frontend/components/session/ConfigWizard.tsx
Multi-step client component. Steps in order:
- Topic — text input for focus group topic (max 1,000 chars, client-side enforced)
- Personas — add 1–10 personas; each has display_name and archetype text fields; archetype max 200 chars
- Upload — optional PRD/deck upload using
FileUploader; validates MIME type and file size < 50 MB client-side before requesting pre-signed URL; shows ingestion progress by pollinggetSession()every 3 seconds until status is no longeringesting - Review & Launch — summary of configuration; "Launch Session" button calls
launchSession()then navigates to/sessions/{sessionId}
File uploader flow:
- Call
requestUploadUrl()→ getpresigned_urlandupload_id PUTfile bytes directly topresigned_urlviauploadFileToS3()- Call
confirmUpload(sessionId, upload_id) - Begin polling session status
Auth module
frontend/lib/auth.ts
// Wraps Amplify Auth SDK
// signIn(email, password): Promise<void>
// signOut(): Promise<void>
// getIdToken(): Promise<string> — used by api-client.ts and websocket-client.ts
// getCurrentUser(): Promise<{ userId: string; email: string } | null>
Deliverables:
- A user can sign up, confirm email, and land on the workspace dashboard with no errors
- A user can complete the 4-step wizard, upload a PDF, wait for ingestion, and launch a session
- The live debate view shows agent messages appearing in real time as the orchestration worker publishes them
- On WebSocket disconnect and reconnect, the transcript is rebuilt from the REST API with no duplicates or gaps
- The report viewer renders the analyst report's structured JSON fields (positioning, pricing, features, dissenting views) after session completion
- A workspace owner can invite a member by email; the invited user receives an SES email (in staging: verified address)
Phase 8: GDPR Erasure Workflow and Data Lifecycle
This phase implements the GDPR right-to-erasure Step Functions state machine, the checkpoint pruning job for completed sessions, and the product context purge on session deletion. These are all correctness-critical operations for compliance — each step must be independently retryable and auditable.
Contracts
GDPR erasure state machine
Implemented as an AWS Step Functions Standard Workflow. Defined in CDK. The state machine ARN is stored in an environment variable on the FastAPI service: ERASURE_STATE_MACHINE_ARN.
Steps in order (each step is a Lambda function):
soft_delete_user— Setsusers.erasure_requested_at = NOW()and marks the user inactive in Cognito (admin disable user); writes agdpr_audit_logrow with action'erasure_started'erase_message_content— Updates allmessages.content = '[erased]'WHEREtenant_id = :tenant_id; this is a bulk UPDATE, not row-by-row deletiondelete_s3_uploads— Lists and deletes all S3 objects under prefixuploads/{tenant_id}/; lists and deletes all S3 objects underreports/{tenant_id}/; handles paginated listingpurge_product_context_vectors— Deletes allproduct_context_chunksWHEREtenant_id = :tenant_idpurge_ethnographic_vectors— Deletes allethnographic_chunksWHEREtenant_id = :tenant_id(user-supplied grounding only; base corpus rows have NULL tenant_id and are unaffected)purge_langgraph_checkpoints— Querieslanggraph_thread_tenant_mapfor allthread_idvalues WHEREtenant_id = :tenant_id; deletes corresponding rows fromAsyncPostgresSavercheckpoint tables (checkpointsandcheckpoint_writes) by thread_id; deletes rows fromlanggraph_thread_tenant_mapwrite_audit_completion— Writes agdpr_audit_logrow with action'erasure_completed'; callssend_gdpr_erasure_confirmation()via SES (non-blocking — email failure does not fail this step)
Each Lambda step uses the same error handling contract: retries up to 3 times with increasing delay before the Step Functions state machine moves to a failure state and publishes to SNS. Steps are idempotent — re-running a step that already completed leaves state unchanged.
GDPR erasure trigger endpoint
backend/control_plane/routers/gdpr.py
POST /gdpr/erasure
body: ErasureRequest(user_id: str)
returns: ErasureStarted(execution_arn: str)
# Restricted to platform admin role (checked via a separate Cognito group claim)
# Starts the Step Functions execution with input: {"tenant_id": ..., "user_id": ...}
# Returns the execution ARN immediately; completion is async
Checkpoint pruning job
backend/orchestration_worker/cleanup/prune_checkpoints.py — runs as a scheduled ECS task (EventBridge Scheduler, daily):
async def prune_old_checkpoints() -> None:
# Queries sessions WHERE status IN ('completed', 'failed') AND updated_at < NOW() - INTERVAL '30 days'
# For each: deletes rows from AsyncPostgresSaver checkpoint tables keyed to the session's thread_id
# Deletes the langgraph_thread_tenant_map row
# Logs count of pruned sessions
Session deletion purge
Called from DELETE /sessions/{session_id} in Phase 5's session router (not a separate service). Inline in the delete handler:
async def purge_session_vectors(session_id: str, tenant_id: str, db: AsyncSession) -> None:
# Deletes all product_context_chunks WHERE session_id = :session_id AND tenant_id = :tenant_id
# Does not touch ethnographic_chunks (grounding docs are tenant-scoped, not session-scoped)
Deliverables:
- Triggering the erasure state machine against a test tenant in staging results in all seven steps completing successfully: message content replaced with
[erased], S3 objects deleted, pgvector rows purged, checkpoint rows deleted, audit log entry written - A partial failure in step 3 (S3 deletion) causes that step to retry up to 3 times; on repeated failure the state machine enters a failure state and publishes to SNS (verified by simulating an S3 permission error)
- The checkpoint pruning job runs in staging and removes checkpoint rows for sessions older than 30 days in
completedorfailedstatus DELETE /sessions/{session_id}removes allproduct_context_chunksfor that session from pgvector- A pgvector ANN query after erasure returns zero rows for the erased tenant's data
Phase 9: Observability, Hardening, and Production Readiness
This phase adds structured logging with correlation IDs across all services, X-Ray trace propagation, the remaining CloudWatch alarms, WAF configuration, and the load and failover tests that verify the system's behavior at scale before going to production.
Contracts
Structured logging
backend/shared/logging_config.py
def configure_logging(service_name: str) -> None:
# Configures Python logging to output newline-delimited JSON to stdout
# Every log record includes: service, environment, correlation_id (from context var), session_id (if set), timestamp, level, message
# No environment variable values are included in log output (secrets masked)
# Context variables (Python contextvars):
correlation_id_var: ContextVar[str] # set at request start; propagated to all log calls within the request
session_id_var: ContextVar[str | None] # set by worker on startup
Every FastAPI request generates a UUID correlation ID at middleware entry. It is returned in the X-Correlation-ID response header and included in every log line for the request. Orchestration workers generate a correlation ID per node transition.
X-Ray trace propagation
- FastAPI: X-Ray SDK middleware applied at app startup; patches
asyncpg,aiobotocore, andaioredisautomatically - Orchestration worker: X-Ray SDK applied; patches Bedrock client and asyncpg; trace IDs logged alongside correlation IDs
- API Gateway: X-Ray active tracing enabled (configured in CDK AppStack)
CloudWatch alarms (additions to Phase 1 baseline)
All defined in CDK AppStack:
- Worker task
session_pausedevents (custom metric from CloudWatch Logs metric filter): > 10 per minute → warning alarm - Median
turn_latency_seconds(custom metric from worker logs) > 30 seconds sustained 5 minutes → P2 alarm - Ingestion DLQ depth > 0 → P2 alarm (already in Phase 1; confirmed wired to SNS)
- RDS write IOPS > 3,000 sustained 5 minutes → P1 alarm (already in Phase 1)
- ElastiCache connection count > 80% of
maxclients→ P2 alarm
Load test suite
tests/load/ — uses locust or direct asyncio scripts; not a production runtime dependency:
500_sessions_concurrency.py: spawns 500 simulated sessions against staging, each running a 2-persona, 2-round graph; asserts RDS write IOPS remain below 3,000; asserts median turn latency < 30 secondsbedrock_throttle_recovery.py: simulates sustained Bedrock throttling (via mocked Bedrock endpoint in staging); asserts sessions enterpausedstatus and are resumablefargate_spot_interruption.py: manually stops 10 worker tasks; asserts all 10 are detected by heartbeat monitor within 90 seconds and resume successfully from RDS checkpointsredis_failover.py: triggers ElastiCache primary failover; asserts sessions resume pushing after the failover window; asserts clients can retrieve missed messages byturn_sequencegdpr_erasure_integrity.py: runs erasure against a test tenant with active sessions; asserts zero data remains across RDS, S3, and pgvector after completion
WAF rules (finalized in CDK)
- Rate limit: 100 requests per 5-minute window per IP to the REST API
- Rate limit: 10 WebSocket
$connectattempts per 5-minute window per IP - AWS Managed Rules:
AWSManagedRulesCommonRuleSet,AWSManagedRulesSQLiRuleSet - Body size limit: reject requests with body > 52,428,800 bytes (50 MB) — matches the upload validation
Pre-launch checklist (verified in staging before production deploy)
Each item maps to a load test or manual verification:
- Bedrock quota increase confirmed received for Claude claude-sonnet-4-5 tokens per minute
- 500-session concurrent load test passes (RDS IOPS < 3,000, turn latency < 30s)
- Fargate Spot interruption recovery verified (10 sessions, all resume within 90s)
- GDPR erasure verified against a test tenant (all data purged, audit log written)
- Redis failover recovery verified (sessions resume push, clients recover missed messages)
- WAF rate limiting verified (IP blocked after 100 requests in 5 minutes returns 429)
- CloudWatch alarms verified: each alarm fires as expected when its threshold is breached in staging
Deliverables:
- Every API response includes an
X-Correlation-IDheader; every log line for that request includes the same ID - CloudWatch Logs Insights query on
correlation_idreturns all log lines for a given request across FastAPI, worker, and Lambda services - X-Ray service map in the AWS console shows a complete trace from API Gateway through FastAPI through RDS for a session launch request
- The 500-session load test completes with RDS write IOPS staying below 3,000 and no sessions failing due to checkpoint errors
- WAF blocks an IP that exceeds the rate limit and returns a generic 429 with no internal details
- All five load test scripts pass in staging; results documented in a staging sign-off artifact before the manual production approval gate is opened
Tickets
Tickets Document
Here is your step-by-step ticket backlog to build MockBoard. Whether you're pasting these sequentially into Claude Code or importing them into Linear for your team, these tickets provide the exact implementation steps to bring this architecture to life.
Phase 1: Infrastructure Foundation
Ticket 1.1: Create Network Foundation Stack
Objective: Provision the foundational AWS networking infrastructure, including the VPC, subnets across multiple availability zones, security groups, and VPC endpoints.
Context: This ticket establishes the secure and isolated network environment required by all other services, as specified in the Architecture Design Document. Creating the NetworkStack first allows subsequent data and compute resources to be placed into the correct, pre-configured private subnets with appropriate firewall rules.
Technical Directives:
- Implement a CDK stack named
NetworkStackininfra/lib/network-stack.ts. - Create a new VPC spanning two Availability Zones.
- Configure three subnet tiers within the VPC:
public,private-app, andprivate-data. - Create a gateway VPC endpoint for S3.
- Create interface VPC endpoints for Bedrock, Secrets Manager, and ECR.
- Define security groups for the ALB, FastAPI service, orchestration/ingestion workers, RDS, and Redis.
- Configure security group ingress and egress rules precisely as defined in the phase contract, allowing traffic only from specified sources (e.g., RDS only accepts traffic from the application and worker security groups).
- The stack must export its created VPC, subnet, and security group resources for consumption by other stacks.
Scope Boundaries:
- AWS CDK:
infra/lib/network-stack.ts
Acceptance Criteria:
- The
NetworkStackCDK stack deploys successfully. - A VPC with public, private-app, and private-data subnets exists in the AWS account.
- All specified security groups are created with the correct ingress/egress rules.
- All specified VPC endpoints are created and associated with the correct VPC route tables.
- The stack's outputs (VPC, subnets, security groups) are available for import by other CDK stacks.
Dependencies: None
Ticket 1.2: Create Data and Storage Stack
Objective: Provision all stateful data stores, including the PostgreSQL database, Redis cache, S3 buckets, SQS queues, and associated monitoring.
Context: This ticket creates the persistence and messaging layer defined in the DataStack contract, using the network foundation from the previous ticket. The Architecture Design Document specifies using managed AWS services (RDS, ElastiCache, S3, SQS) to minimize operational overhead for the team.
Technical Directives:
- Implement a CDK stack named
DataStackininfra/lib/data-stack.ts. - Provision an RDS PostgreSQL 16 instance (db.r7g.large) in the
private-datasubnets, configured for Multi-AZ. - Enable the
pgvectorextension on the RDS instance. - Configure RDS storage as 500 GB gp3 with auto-scaling up to 2 TB.
- Provision an ElastiCache for Redis 7.x cluster (cache.r7g.large) in the
private-datasubnets, configured for Multi-AZ with automatic failover and cluster mode disabled. - Retrieve the Redis authentication token from AWS Secrets Manager.
- Create two S3 buckets: one for uploads and one for reports, both with versioning, server-side KMS encryption, and Block Public Access enabled.
- Configure an S3 event notification on the uploads bucket to send messages to an SQS ingestion queue.
- Create three SQS queues:
ingestion,ingestion-dlq, andcompletion. The ingestion queue must be configured to move messages to the DLQ after 4 failed receives and retain messages for 24 hours. - Create a CloudWatch alarm that triggers an SNS topic if the RDS instance's write IOPS exceeds 3,000 for a sustained 5-minute period.
- The stack must export the names, ARNs, and endpoints of its created resources.
Scope Boundaries:
- AWS CDK:
infra/lib/data-stack.ts
Acceptance Criteria:
- The
DataStackCDK stack deploys successfully. - The RDS instance is running, accessible from the
private-appsubnets, and has thepgvectorextension available. - The ElastiCache for Redis cluster is running and accessible from the
private-appsubnets. - Both S3 buckets and all three SQS queues are created with the specified configurations.
- The CloudWatch alarm for RDS write IOPS is created and configured.
- The created RDS and Redis instances are ready to be configured with the schema and data models defined in the Following Phase.
Dependencies: Ticket 1
Ticket 1.3: Create Compute and Container Stack
Objective: Provision the AWS ECS Fargate compute infrastructure, including the cluster, ECR repositories, Application Load Balancer, and task definitions for all backend services.
Context: This ticket defines the runtime environments for the application's containerized services, as outlined in the Architecture Design Document. It implements the core "one task per session" strategy for orchestration workers and sets up the auto-scaling control plane service.
Technical Directives:
- Implement a CDK stack named
ComputeStackininfra/lib/compute-stack.ts. - Create an ECS cluster with Container Insights enabled.
- Create three ECR repositories:
fastapi-control-plane,orchestration-worker, andingestion-worker. - Provision an internet-facing Application Load Balancer in the public subnets.
- Define an ECS task definition for the FastAPI control plane (1 vCPU, 2GB RAM) with an IAM role granting permissions for SQS, ECS RunTask, Secrets Manager, SES, S3, and Bedrock.
- Define an ECS task definition for the orchestration worker (2 vCPU, 4GB RAM) with an IAM role granting permissions for RDS, ElastiCache, API Gateway Management API, S3, Bedrock, and Secrets Manager.
- Define an ECS task definition for the ingestion worker (1 vCPU, 2GB RAM) with an IAM role granting permissions for SQS, S3, Bedrock, and Secrets Manager.
- Configure an ECS Service for the FastAPI control plane with a minimum of 2 and a maximum of 20 tasks, scaling based on an ALB request count target of 1,000 RPM.
- Configure an ECS Service for the ingestion worker with a minimum of 0 and a maximum of 10 tasks, scaling based on the depth of the SQS ingestion queue.
- Attach an AWS WAF WebACL to the ALB with rules for rate limiting, SQL injection, and request size constraints.
- Configure the orchestration worker task definition to use a Fargate Spot capacity provider with an on-demand fallback.
- The stack must export the ARNs and names of its created resources.
Scope Boundaries:
- AWS CDK:
infra/lib/compute-stack.ts
Acceptance Criteria:
- The
ComputeStackCDK stack deploys successfully. - The ECS cluster and all three ECR repositories are created.
- The ALB is created and correctly forwards traffic to the FastAPI service's target group.
- All three ECS task definitions and the two ECS services are created with their specified configurations and IAM roles.
- The ECR repositories are ready to receive Docker images from the CI/CD pipeline.
- The task definitions are ready to be used by the application code deployments in subsequent phases.
Dependencies: Ticket 1, Ticket 2
Ticket 1.4: Create Application Integration Stack
Objective: Provision the application-facing services, including API Gateways, Lambda authorizers and handlers, the Cognito User Pool, and the Amplify frontend application.
Context: This ticket creates the primary user entry points and authentication layer as defined in the AppStack contract. It integrates serverless components like API Gateway and Lambda with the stateful data and compute backends to complete the infrastructure setup.
Technical Directives:
- Implement a CDK stack named
AppStackininfra/lib/app-stack.ts. - Create an API Gateway REST API (HTTP API v2) with a default proxy route to the ALB created in the
ComputeStack. - Create an API Gateway WebSocket API with
$connect,$disconnect, and$defaultroutes. - Create a Cognito User Pool and App Client configured for email/password sign-in and TOTP MFA support.
- Configure a Cognito JWT authorizer on the REST API and a Lambda authorizer on the WebSocket
$connectroute. - Implement the
$connectLambda to read asession_idfrom the query string and store the connection ID in the Redis setws:connections:{session_id}. The Lambda must also write the Management API endpoint URL to the Redis keyws:endpoint. - Implement the
$disconnectLambda to remove the connection ID from the corresponding Redis set. - Implement a Cognito post-confirmation Lambda trigger that will create a new user record in RDS. (Note: The database connection logic will be a placeholder until Phase 2).
- Create an AWS Amplify application configured to point to the project's GitHub repository.
- Configure an SES email identity for sending transactional emails.
- Enable AWS X-Ray tracing on the FastAPI task definition and the API Gateways.
- Create CloudWatch alarms for FastAPI task count, worker failure rate, DLQ depth, API Gateway 5xx rate, ElastiCache node failure, and RDS failover, all reporting to an SNS topic.
Scope Boundaries:
- AWS CDK:
infra/lib/app-stack.ts
Acceptance Criteria:
- The
AppStackCDK stack deploys successfully. - The Cognito User Pool is created and allows user sign-up and sign-in.
- The REST API routes requests to the ALB, and the WebSocket API manages connections.
- Connecting to the WebSocket API successfully triggers the
$connectLambda, which writes the connection ID to the correct key in Redis. - Disconnecting from the WebSocket API triggers the
$disconnectLambda, which removes the connection ID from Redis. - All specified CloudWatch alarms are created and configured.
Dependencies: Ticket 1, Ticket 2, Ticket 3
Ticket 1.5: Implement CI/CD Pipeline Workflows
Objective: Create the GitHub Actions workflow files to automate infrastructure and application linting, testing, and deployment.
Context: This ticket establishes the automated CI/CD pipeline specified in the Deployment Architecture. This pipeline is crucial for ensuring code quality and enabling repeatable, safe deployments to both staging and production environments, with a manual gate for production releases.
Technical Directives:
- Create a GitHub Actions workflow file at
.github/workflows/pr.yml. - Configure the
pr.ymlworkflow to trigger on every pull request to themainbranch. - The
pr.ymlworkflow must execute linting, type-checking, and acdk diffcommand to preview infrastructure changes. - Create a GitHub Actions workflow file at
.github/workflows/deploy.yml. - Configure the
deploy.ymlworkflow to trigger on every merge to themainbranch. - The
deploy.ymlworkflow must build placeholder Docker images for all services, push them to the ECR repositories tagged with the Git SHA, and deploy all CDK stacks to thestagingenvironment. - After the staging deployment, the workflow must include a placeholder for integration tests.
- After the tests, the workflow must use a GitHub Actions environment to require manual approval before proceeding.
- Upon approval, the workflow must deploy all CDK stacks to the
productionenvironment.
Scope Boundaries:
- GitHub Actions configuration files:
.github/workflows/
Acceptance Criteria:
- Creating a pull request against the
mainbranch successfully triggers thepr.ymlworkflow, and thecdk diffstep completes. - Merging a pull request to the
mainbranch successfully triggers thedeploy.ymlworkflow. - The
deploy.ymlworkflow successfully pushes placeholder images to all ECR repositories. - The
deploy.ymlworkflow successfully completes thecdk deploy --allstep for the staging environment. - The
deploy.ymlworkflow correctly pauses and waits for manual approval before the production deployment step.
Dependencies: Ticket 1, Ticket 2, Ticket 3, Ticket 4
Phase 2: Database Schema and Shared Data Layer
Ticket 2.1: Create Database Schema with Alembic Migration
Objective: This ticket creates the initial Alembic migration file that defines all required database tables, columns, constraints, and indexes for the entire application.
Context: This foundational schema supports all core entities described in the Architecture Design Document, including multi-tenancy, session management, message history, and the dual-layer RAG storage (ethnographic_chunks, product_context_chunks). It also includes the langgraph_thread_tenant_map table required for the GDPR erasure workflow to correctly identify and purge LangGraph checkpoint data from RDS.
Technical Directives:
- Create a new Alembic migration script.
- The script must define all tables and columns exactly as specified in the
Phase 2: Database Schemacontract, includingtenants,workspaces,users,workspace_members,sessions,persona_configs,messages,analyst_reports,ethnographic_chunks,product_context_chunks,gdpr_audit_log, andlanggraph_thread_tenant_map. - All primary keys, foreign keys, data types (including ENUMs and
vector(1536)),NOT NULLconstraints, and default values must match the contract. - The script must create HNSW indexes on the
embeddingcolumns ofethnographic_chunksandproduct_context_chunksusing cosine distance. - The script must create all other specified b-tree indexes on foreign keys and commonly queried columns.
- The migration must not create the
AsyncPostgresSavercheckpoint tables; these are managed by the LangGraph library at runtime.
Scope Boundaries:
- Database migration script (
backend/migrations/versions/)
Acceptance Criteria:
- The Alembic migration runs successfully from a clean state against the RDS PostgreSQL instance.
- All specified tables, columns, constraints, and indexes are present in the database after the migration completes.
- The data types for all columns, including ENUMs and vector dimensions, match the specification.
- The HNSW indexes are configured for cosine similarity search.
Dependencies: None
Ticket 2.2: Implement SQLAlchemy ORM Models
Objective: This ticket creates the SQLAlchemy ORM model classes that map directly to the database schema established in the previous ticket.
Context: These ORM models provide a type-safe, object-oriented interface to the database for all backend services (FastAPI control plane, orchestration worker, ingestion worker). As per the Architecture Design Document, every model must support tenant isolation by including a tenant_id attribute, which is fundamental to the system's security architecture.
Technical Directives:
- Create SQLAlchemy declarative model classes for every table defined in the
001_initial_schema.pymigration. - The class names must be
Tenant,Workspace,User,WorkspaceMember,Session,PersonaConfig,Message,AnalystReport,EthnographicChunk,ProductContextChunk,GdprAuditLog, andLangGraphThreadTenantMap. - All model attributes must correctly map to their corresponding database columns, including data types and relationships.
- All models intended for tenant-scoped data must include the
tenant_idattribute.
Scope Boundaries:
- Shared ORM models module (
backend/shared/models.py)
Acceptance Criteria:
- The
backend/shared/models.pymodule can be imported without errors. - Instantiating a model object and accessing its attributes does not raise an error.
- When used with a SQLAlchemy session, the models can be used to successfully insert and retrieve data from their corresponding tables.
Dependencies: Ticket 1
Ticket 2.3: Define Pydantic API and Message Schemas
Objective: This ticket defines the Pydantic models that serve as the data transfer objects (DTOs) for API requests, responses, and internal WebSocket messages.
Context: These schemas establish the formal data contracts between the frontend client, the API layer, and the orchestration workers, as outlined in the Architecture Design Document. Defining these upfront ensures consistency and provides a clear handoff to the Following Phase (Phase 3), which will build the API endpoints that consume and produce these shapes.
Technical Directives:
- Implement all Pydantic V2 models as specified in the
Phase 2: Contractssection. - Create the
SessionStatusandAgentRolestring enums. - Implement the
PersonaConfigCreate,SessionCreate,MessageOut,SessionOut, andWorkspaceMessagePushmodels with the exact field names and types specified. - Use Python's
UUIDanddatetimetypes where appropriate. - Ensure the
personaslist inSessionCreateis correctly typed aslist[PersonaConfigCreate]. - The
WorkspaceMessagePushschema must accommodate different event types and optional payloads as defined in the contract.
Scope Boundaries:
- Shared Pydantic schemas module (
backend/shared/schemas.py)
Acceptance Criteria:
- The
backend/shared/schemas.pymodule can be imported without errors. - Pydantic models can be instantiated with valid data and raise validation errors for invalid data (e.g., wrong types, missing required fields).
- The
SessionCreatemodel correctly validates that the length of thepersonaslist matches thepersona_countvalue in consuming code. - The
WorkspaceMessagePushschema can be used to serialize a payload for theagent_messageevent, which will be used by the orchestration worker in a later phase.
Dependencies: None
Ticket 2.4: Create Shared Database Connection Module
Objective: This ticket creates a reusable Python module for managing the asynchronous database connection pool and sessions.
Context: This module centralizes database connection logic, ensuring that all backend services connect to RDS PostgreSQL consistently. It provides the foundation for application-level Row-Level Security by including the function to set the tenant context on a per-session basis, a key security control from the Architecture Design Document.
Technical Directives:
- Implement the
backend/shared/db.pymodule with the specified asynchronous functions:init_db,get_db_session,set_tenant_context, andclose_db. init_dbmust create a module-level async SQLAlchemy engine and sessionmaker from a provided database URL and register the pgvector extension.get_db_sessionmust be an async generator that yields a newAsyncSession.set_tenant_contextmust execute theSET LOCAL app.current_tenant_idcommand on a given session.close_dbmust properly dispose of the engine resources.
Scope Boundaries:
- Shared database connection module (
backend/shared/db.py)
Acceptance Criteria:
- Calling
init_dbsuccessfully establishes a connection pool to the RDS instance. get_db_sessionyields a functionalAsyncSessionobject that can be used to execute queries.set_tenant_contextsuccessfully sets the session variable in PostgreSQL, verifiable by executingSHOW app.current_tenant_idwithin the same transaction.- Calling
close_dbterminates the connection pool gracefully.
Dependencies: Ticket 1
Ticket 2.5: Implement Row-Level Security Policies and Tenant Context Management
Objective: This ticket applies Row-Level Security (RLS) policies to all tenant-scoped tables in the database to enforce data isolation at the database layer.
Context: RLS is a defense-in-depth security measure mandated by the Architecture Design Document to prevent cross-tenant data leakage, even in the event of an application-layer bug. This ticket implements the PostgreSQL RLS policies and integrates them with the application's tenant context management.
Technical Directives:
- Augment the Alembic migration from Ticket 1 to enable Row-Level Security on the specified tables:
sessions,messages,persona_configs,analyst_reports,product_context_chunks,workspace_members,workspaces, andusers. - For each table, create a policy that permits access only when the row's
tenant_idcolumn matches the value of theapp.current_tenant_idsession setting. - The
set_tenant_contextfunction inbackend/shared/db.pywill be used by the application layer in a later phase to set this session variable for each request.
Scope Boundaries:
- Database migration script (
backend/migrations/versions/) - Shared database connection module (
backend/shared/db.py)
Acceptance Criteria:
- After running the migration, RLS is enabled on all specified tables.
- A test connecting to the database confirms that a
SELECTquery on a protected table returns no rows ifapp.current_tenant_idis not set. - After setting
app.current_tenant_idto Tenant A's UUID, aSELECTquery returns only rows belonging to Tenant A and excludes rows from Tenant B. - The
set_tenant_contextfunction correctly sets the required session variable for the RLS policies to function.
Dependencies: Ticket 1, Ticket 4
Ticket 2.6: Create Shared Redis Client Module
Objective: This ticket creates a reusable Python module for managing the asynchronous connection to the ElastiCache Redis cluster.
Context: This module provides a centralized client for all Redis interactions, as specified in the Architecture Design Document. It will be used by the API layer to manage WebSocket connection IDs and by the orchestration worker for session heartbeating and retrieving connection IDs for pushing messages.
Technical Directives:
- Implement the
backend/shared/redis_client.pymodule with the specified asynchronous functions:init_redis,get_redis, andclose_redis. init_redismust create a module-levelaioredisconnection pool from a provided Redis URL.get_redismust return a Redis client instance from the pool.close_redismust properly close the connection pool.- The module must define the exact string constants for Redis keys as specified in the contract:
WS_CONNECTIONS_KEY,HEARTBEAT_KEY, andMANAGEMENT_ENDPOINT_KEY.
Scope Boundaries:
- Shared Redis client module (
backend/shared/redis_client.py)
Acceptance Criteria:
- Calling
init_redissuccessfully connects to the ElastiCache Redis instance. - The client returned by
get_rediscan successfully execute Redis commands (e.g.,SET,GET,SADD). - The defined key constants (
WS_CONNECTIONS_KEY, etc.) are importable and correctly formatted for use with string substitution. - Calling
close_rediscloses the connection pool without errors.
Dependencies: None
Ticket 2.7: Define Shared Custom Error Classes
Objective: This ticket establishes a standardized set of custom exception classes for handling application-specific errors consistently across all backend services.
Context: As per the Error Handling Policy in the Architecture Design Document, the system must return generic error messages to clients while logging detailed internal information. These custom exceptions provide the mechanism for services to signal specific failure modes (e.g., authorization failure, invalid state transition) which can then be caught by a global exception handler in the API layer.
Technical Directives:
- Create the
backend/shared/errors.pymodule. - Define the base
AppErrorclass which accepts a message and a correlation ID. - Define the specific exception classes that inherit from
AppError:AuthorizationError,ValidationError,SessionStateError,IngestionError,CheckpointError,LLMError, andCapacityError.
Scope Boundaries:
- Shared errors module (
backend/shared/errors.py)
Acceptance Criteria:
- The
backend/shared/errors.pymodule can be imported without errors. - Each custom exception class can be raised and caught correctly.
- An instance of
AppErroror its subclasses correctly stores the message and correlation ID passed to its constructor.
Dependencies: None
Ticket 2.8: Implement Shared Text Sanitization Utility
Objective: This ticket creates a reusable function for sanitizing user-provided text inputs to mitigate security risks like prompt injection.
Context: The Architecture Design Document's Input Validation section requires that all user-supplied text be sanitized before being used in LLM prompts or stored in the database. This centralized utility ensures that sanitization is applied consistently by all services that process user input.
Technical Directives:
- Create the
backend/shared/sanitize.pymodule. - Implement the function
sanitize_user_textwhich accepts a string and a maximum length integer. - The function must remove Unicode control characters in the
CcandCfcategories, while preserving essential whitespace like tabs and newlines. - The function must truncate the input string to the specified maximum length.
Scope Boundaries:
- Shared sanitization module (
backend/shared/sanitize.py)
Acceptance Criteria:
- The
sanitize_user_textfunction returns a string with control characters (e.g.,\x00,\x08) removed. - The function correctly truncates a string that is longer than the
max_lengthargument. - The function preserves standard characters, numbers, punctuation, and essential whitespace.
- A string shorter than
max_lengthwith no control characters is returned unchanged.
Dependencies: None
Phase 3: Authentication and Tenant Management
Ticket 3.1: FastAPI Application Entry Point and Core Middleware
Objective: Establish the main FastAPI application shell with essential middleware for security, error handling, and lifecycle management.
Context: This ticket creates the foundational structure of the control_plane service as described in the Architecture Design Document. It implements the application entry point, including startup and shutdown events, global exception handling, and security headers, which are required before any API routes can be added.
Technical Directives:
- Implement a FastAPI application in
backend/control_plane/main.py. - Create a lifespan context manager that calls
init_db()andinit_redis()from the shared modules on startup, andclose_db()andclose_redis()on shutdown. - Configure CORS middleware to allow only the origin specified in an environment variable.
- Implement middleware to add the following security headers to all responses:
Strict-Transport-Security,X-Content-Type-Options,X-Frame-Options, andContent-Security-Policy. - Implement a global exception handler that catches all
AppErrorsubclasses defined inbackend/shared/errors.py. - The exception handler must log the full error details and return a generic JSON error response to the client containing a correlation ID, without exposing internal details like stack traces.
- Enable AWS X-Ray middleware for distributed tracing.
Scope Boundaries:
- FastAPI application entry point (
main.py) - Core application middleware configuration
Acceptance Criteria:
- The application starts without errors, successfully initializing database and Redis connections.
- A request from an allowed origin receives a response with the correct CORS headers.
- A request from a disallowed origin is blocked by CORS policy.
- All API responses include the specified security headers (
HSTS,X-Content-Type-Options, etc.). - When a route handler raises a custom
AppError, the client receives a JSON response with a generic message and a correlation ID, and the full error is logged.
Dependencies: None
Ticket 3.2: Cognito JWT Verification Middleware
Objective: Implement middleware to validate Cognito JWTs and extract user claims for authenticated requests.
Context: This ticket provides the core authentication mechanism for the entire API, as outlined in the Architecture Design Document. It creates a reusable FastAPI dependency that all protected endpoints will use to ensure requests are from authenticated users and to establish tenant context for database queries.
Technical Directives:
- In
backend/control_plane/auth/middleware.py, create theverify_cognito_tokenfunction as specified in the phase contract. - The function must fetch the JSON Web Key Set (JWKS) from the Cognito User Pool endpoint.
- The JWKS response must be cached in memory with a time-to-live (TTL) to avoid excessive requests.
- The function must validate the JWT's signature, expiry, issuer, and audience against the configured Cognito User Pool details.
- On successful validation, the function must return a
TokenClaimsobject populated with claims from the token. - If validation fails for any reason, the function must raise an
AuthorizationError. - Create a FastAPI dependency (
get_current_user) that injects theget_db_sessiondependency, retrieves and validates the token viaverify_cognito_token, and executesset_tenant_context(db, tenant_id)on the yieldedAsyncSessionbefore returning the user claims.
Scope Boundaries:
- Authentication middleware module
Acceptance Criteria:
- A request to an endpoint using the dependency with a valid JWT succeeds.
- A request with an expired JWT is rejected with an
AuthorizationError. - A request with an invalid signature is rejected with an
AuthorizationError. - A request with a missing
Authorizationheader is rejected with anAuthorizationError. - On a successful authenticated request, the
app.current_tenant_idsession variable is correctly set in the database connection before the route handler executes.
Dependencies: 1
Ticket 3.3: Cognito Post-Confirmation Lambda Function
Objective: Create a Lambda function to provision a new user, tenant, and workspace in the database when a user confirms their account in Cognito.
Context: This function automates the user onboarding process, linking the Cognito identity to the application's internal data model as described in the ADD. It ensures that every new user who signs up is correctly provisioned with their own tenant and default workspace, enabling the bootstrap flow.
Technical Directives:
- Create a Lambda function defined in
backend/lambdas/cognito_post_confirmation/handler.py. - The handler must parse the user's
sub,email, and custom attributes from the Cognito PostConfirmation trigger event. - If the
custom:tenant_idattribute is absent, the function must create a newTenantrecord in the database, using thecustom:gdpr_regionattribute. - The function must create a
Userrecord in the database, linking the Cognitosubto the newly created or existing tenant ID. - The function must create a default
Workspacefor the new tenant. - The function must create a
WorkspaceMemberrecord, making the new user anownerof the default workspace. - The function must use a direct
psycopg2connection, not the shared SQLAlchemy engine, as specified in the contract. - The handler must return the event object unchanged to satisfy Cognito's trigger requirements.
- The Lambda function must be explicitly configured (via CDK in the AppStack) to run inside the VPC's private-app subnets and attached to a security group that allows outbound traffic to the RDS instance's security group, otherwise it will silently time out connecting to the database.
Scope Boundaries:
- Cognito Lambda function module
Acceptance Criteria:
- When a new user confirms their email in Cognito, the Lambda is triggered.
- A
Tenant,User,Workspace, andWorkspaceMemberrecord are successfully created in the RDS database. - The
User.idin the database matches the Cognito user'ssub. - The
Tenant.idis correctly associated with the newUser. - The Lambda function completes successfully and does not cause the Cognito confirmation flow to fail.
Dependencies: None
Ticket 3.4: Tenant Management API Router
Objective: Implement the API endpoints for creating and retrieving tenant information.
Context: This ticket builds the first set of business logic endpoints, allowing new users to bootstrap their accounts and retrieve their tenant details. This is a foundational part of the multi-tenant architecture and depends on the JWT middleware to identify the user.
Technical Directives:
- Create the tenant router in
backend/control_plane/routers/tenants.py. - Implement the
POST /tenantsendpoint, which is only callable by a user who does not yet have atenant_idclaim. - The
POST /tenantsendpoint must create aTenant, a defaultWorkspace, and aWorkspaceMemberrecord making the caller the owner. It should return aTenantOutschema. - Implement the
GET /tenants/meendpoint. - This endpoint must use the
get_current_userdependency to get the authenticated user's claims. - It must query and return the
Tenantrecord corresponding to thetenant_idfrom the user's JWT claims.
Scope Boundaries:
- FastAPI router module (
tenants.py)
Acceptance Criteria:
- A POST request to
/tenantswith valid input creates the tenant, workspace, and workspace member records in the database and returns a 200 response with the tenant data. - A GET request to
/tenants/meby an authenticated user returns the correct tenant information for that user. - An authenticated user cannot call
POST /tenantsa second time. - A request to
/tenants/mewithout a valid JWT token fails with an authentication error.
Dependencies: 2
Ticket 3.5: User Profile API Router
Objective: Implement API endpoints for retrieving and updating the current user's profile.
Context: This provides the basic user profile management functionality. It allows users to view their own information and update mutable fields like their display name, which is a standard feature for any multi-user application.
Technical Directives:
- Create the user router in
backend/control_plane/routers/users.py. - Both endpoints must use the
get_current_userdependency for authentication and to identify the user. - Implement the
GET /users/meendpoint to query and return theUserrecord matching theuser_idfrom the JWT claims. - Implement the
PATCH /users/meendpoint to update thedisplay_nameof the current user.
Scope Boundaries:
- FastAPI router module (
users.py)
Acceptance Criteria:
- A GET request to
/users/meby an authenticated user returns their own user profile data. - A PATCH request to
/users/mewith a newdisplay_nameupdates the corresponding record in the database and returns the updated user profile. - A user cannot view or modify another user's profile through these endpoints.
- A request to either endpoint without a valid JWT fails with an authentication error.
Dependencies: 2
Ticket 3.6: Workspace Authorization Logic
Objective: Create a reusable authorization function to enforce role-based access control within workspaces.
Context: This ticket implements the core authorization logic that determines if a user has sufficient permissions to perform an action on a workspace. This function will be used as a dependency in the workspace router to protect endpoints according to the business rules defined in the ADD.
Technical Directives:
- In
backend/control_plane/auth/authorization.py, implement therequire_workspace_rolefunction as specified in the phase contract. - The function must accept a
workspace_id,user_id,minimum_role, and a database session. - It must query the
workspace_memberstable to find the user's role for the given workspace. - It must raise an
AuthorizationErrorif the user is not a member of the workspace or if their role is lower than theminimum_role. - The role hierarchy is
viewer<editor<owner.
Scope Boundaries:
- Authorization logic module
Acceptance Criteria:
- The function does not raise an error if the user's role meets or exceeds the minimum requirement.
- The function raises an
AuthorizationErrorif the user's role is below the minimum requirement (e.g., aviewerattempting aneditoraction). - The function raises an
AuthorizationErrorif the user is not a member of the specified workspace.
Dependencies: 2
Ticket 3.7: Workspace Management API Router
Objective: Implement the API endpoints for creating, listing, and retrieving workspaces.
Context: This ticket delivers the core functionality for managing workspaces, which are the primary containers for sessions. It uses the authentication middleware from Ticket 2 and the authorization logic from Ticket 6 to ensure all operations are secure and correctly scoped to the user's tenant and permissions.
Technical Directives:
- Create the workspace router in
backend/control_plane/routers/workspaces.py. - Implement
GET /workspacesto list all workspaces within the user's tenant. - Implement
POST /workspacesto create a new workspace within the user's tenant. The user who creates the workspace must be made itsowner. - Implement
GET /workspaces/{workspace_id}to retrieve a single workspace by its ID. - The
GET /workspaces/{workspace_id}endpoint must use therequire_workspace_roledependency to ensure the user has at leastviewerpermissions. - All endpoints must use the
get_current_userdependency.
Scope Boundaries:
- FastAPI router module (
workspaces.py)
Acceptance Criteria:
- A GET request to
/workspacesreturns a list of workspaces belonging only to the user's tenant. - A POST request to
/workspacescreates a newWorkspaceandWorkspaceMemberrecord and returns the new workspace data. - A GET request to
/workspaces/{workspace_id}by a user withviewerrights succeeds. - A GET request to
/workspaces/{workspace_id}by a user who is not a member of that workspace is rejected with anAuthorizationError. - A request to any endpoint that attempts to access a workspace in another tenant fails.
Dependencies: 2, 6
Ticket 3.8: SES Email Helper Module
Objective: Create a shared module for sending transactional emails via Amazon SES.
Context: This ticket centralizes the logic for sending emails, as required by various parts of the application. The first consumer will be the workspace member invitation flow, but this module is designed to be reused for other notifications as specified in the ADD.
Technical Directives:
- Create the email helper module in
backend/shared/email.py. - Implement the
send_workspace_invitationfunction. - This function must construct and send an email using the AWS SES API.
- If the SES API call fails, the function must log the error and enqueue a message to a dead-letter queue for later inspection/retry. It must not raise an exception that would fail the parent API request.
- Use the
SES_SENDER_ADDRESSenvironment variable for the "from" address.
Scope Boundaries:
- Shared email module
Acceptance Criteria:
- Calling
send_workspace_invitationwith a valid recipient address results in an email being sent via SES. - If the SES service is unavailable (simulated via mocking), the function logs an error, enqueues a message, and returns successfully without raising an exception.
Dependencies: None
Ticket 3.9: Workspace Member Management Endpoints
Objective: Implement API endpoints for inviting and removing workspace members.
Context: This ticket completes the workspace management functionality by allowing workspace owners to manage their team's access. It integrates the authorization logic and the SES email helper to create a secure and user-friendly invitation flow.
Technical Directives:
- Add new endpoints to the workspace router in
backend/control_plane/routers/workspaces.py. - Implement
POST /workspaces/{workspace_id}/members. - This endpoint must use the
require_workspace_roledependency to ensure the caller is anowner. - It must create a
WorkspaceMemberrecord for the invited user. - It must call the
send_workspace_invitationfunction from the email helper module. - Implement
DELETE /workspaces/{workspace_id}/members/{user_id}. - This endpoint must use the
require_workspace_roledependency to ensure the caller is anowner. - It must delete the specified
WorkspaceMemberrecord.
Scope Boundaries:
- FastAPI router module (
workspaces.py)
Acceptance Criteria:
- A POST request to
/workspaces/{workspace_id}/membersby a workspace owner creates aWorkspaceMemberrecord and sends an invitation email. - An attempt to invite a member by a non-owner is rejected with an
AuthorizationError. - A DELETE request to
/workspaces/{workspace_id}/members/{user_id}by an owner removes the correspondingWorkspaceMemberrecord. - An attempt to remove a member by a non-owner is rejected with an
AuthorizationError.
Dependencies: 7, 8