# Phase 6: Performance & Production Hardening — Pattern Map **Mapped:** 2026-06-02 **Files analyzed:** 12 **Analogs found:** 9 / 12 --- ## File Classification | New/Modified File | Role | Data Flow | Closest Analog | Match Quality | |-------------------|------|-----------|----------------|---------------| | `backend/deps/utils.py` | utility | request-response | `backend/deps/utils.py` (self) | self-update | | `backend/main.py` | config/wiring | request-response | `backend/main.py` (self) | self-update | | `backend/api/auth.py` | controller | request-response | `backend/api/auth.py` (self) | self-update | | `backend/api/documents.py` | controller | CRUD | `backend/api/auth.py` | role-match | | `backend/api/cloud.py` | controller | request-response | `backend/api/auth.py` | role-match | | `backend/services/logging.py` | service | event-driven | `backend/services/auth.py` | role-match | | `backend/config.py` | config | — | `backend/config.py` (self) | self-update | | `backend/load_tests/locustfile.py` | test | request-response | `backend/tests/conftest.py` | partial-match | | `backend/Dockerfile` | config | — | `backend/Dockerfile` (self) | self-update | | `docker-compose.yml` | config | — | `docker-compose.yml` (self) | self-update | | `docker/loki/loki-config.yaml` | config | — | none | no-analog | | `docker/loki/promtail-config.yaml` | config | — | none | no-analog | | `RUNBOOK.md` | documentation | — | none | no-analog | --- ## Pattern Assignments ### `backend/deps/utils.py` (utility, request-response) — D-11 **Change type:** Replace function body in-place. The function `get_client_ip` already exists and is imported by every router that does audit logging (`auth.py`, `documents.py`, etc.). The body must be replaced with trusted-proxy CIDR logic. Do NOT rename or add a second function. **Current body** (`backend/deps/utils.py` lines 10–22): ```python def get_client_ip(request: Request) -> Optional[str]: """Extract best-effort client IP from request for audit logging. TRUST BOUNDARY: X-Forwarded-For is a client-controlled header and can be forged by any caller. ... """ return request.headers.get("X-Forwarded-For") or ( request.client.host if request.client else None ) ``` **Replacement pattern** (from RESEARCH.md Pattern 3): ```python import ipaddress from typing import Optional from fastapi import Request _TRUSTED_PROXY_NETS = [ ipaddress.ip_network("127.0.0.0/8"), ipaddress.ip_network("172.16.0.0/12"), ipaddress.ip_network("192.168.0.0/16"), ipaddress.ip_network("::1/128"), ] def _is_trusted_proxy(host: str) -> bool: try: addr = ipaddress.ip_address(host) return any(addr in net for net in _TRUSTED_PROXY_NETS) except ValueError: return False def get_client_ip(request: Request) -> Optional[str]: """Extract client IP with trusted-proxy CIDR check (D-11). If the direct peer (request.client.host) is a trusted proxy, read the leftmost address from X-Forwarded-For. Otherwise ignore forwarded headers and return the direct peer IP — prevents header spoofing from external clients. """ direct_peer = request.client.host if request.client else None if direct_peer and _is_trusted_proxy(direct_peer): xff = request.headers.get("X-Forwarded-For") if xff: return xff.split(",")[0].strip() return direct_peer ``` **Existing import callers** (no changes required in these files — they already import the right name): - `backend/api/auth.py` line 34: `from deps.utils import get_client_ip` - (all other routers that call `get_client_ip(request)`) **TRUSTED_PROXY_CIDRS config hook:** The list `_TRUSTED_PROXY_NETS` should be built from `settings.trusted_proxy_cidrs` (added in `config.py`) rather than hardcoded. The hardcoded list above is the safe default; read from config on module import after `settings` is available. --- ### `backend/main.py` (config/wiring, request-response) — D-01, D-12 **Change type:** Add `CorrelationIDMiddleware` class, import `setup_logging`, wire `account_limiter` state. **Existing middleware pattern** (`backend/main.py` lines 24–131) — copy exactly for the new raw-ASGI middleware class: ```python # Existing pattern for BaseHTTPMiddleware (lines 25–42): class SecurityHeadersMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): response = await call_next(request) response.headers["Content-Security-Policy"] = "..." return response # Existing app.add_middleware() calls (lines 108–131): app.state.limiter = auth_limiter # line 109 app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # line 110 app.add_middleware(SlowAPIMiddleware) # line 111 app.add_middleware(SecurityHeadersMiddleware) # line 119 app.add_middleware(CORSMiddleware, ...) # line 122-128 app.add_middleware(OriginValidationMiddleware) # line 131 ``` **New CorrelationIDMiddleware** — use raw ASGI (NOT BaseHTTPMiddleware) to avoid response buffering. Must be registered LAST so it runs FIRST in the request chain (Starlette reverse-insertion order): ```python # Add import block additions to main.py: import uuid import time import structlog from starlette.types import ASGIApp, Receive, Scope, Send logger = structlog.get_logger() class CorrelationIDMiddleware: """Generate per-request correlation ID; bind to structlog contextvars. Uses raw ASGI (not BaseHTTPMiddleware) to avoid response-body buffering. Register LAST so it runs FIRST (Starlette reverse-insertion order). """ def __init__(self, app: ASGIApp) -> None: self.app = app async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] != "http": await self.app(scope, receive, send) return correlation_id = str(uuid.uuid4()) start_ns = time.perf_counter_ns() structlog.contextvars.clear_contextvars() structlog.contextvars.bind_contextvars( correlation_id=correlation_id, path=scope.get("path", ""), method=scope.get("method", ""), ) async def send_with_header(message): if message["type"] == "http.response.start": headers = list(message.get("headers", [])) headers.append( (b"x-correlation-id", correlation_id.encode()) ) message = {**message, "headers": headers} await send(message) await self.app(scope, receive, send_with_header) duration_ms = (time.perf_counter_ns() - start_ns) / 1_000_000 structlog.contextvars.bind_contextvars(duration_ms=round(duration_ms, 2)) ``` **Lifespan hook — call setup_logging first** (`backend/main.py` lines 67–101 — insert before the `yield`): ```python # In lifespan(), before the existing minio init: from services.logging import setup_logging setup_logging( json_logs=settings.log_json, log_level=settings.log_level, ) ``` **account_limiter wiring** — add alongside existing `app.state.limiter = auth_limiter`: ```python # In main.py, alongside app.state.limiter = auth_limiter (line 109): from services.rate_limiting import account_limiter # or wherever it lives # account_limiter decorators work independently; no app.state wiring needed # SlowAPIMiddleware only tracks the limiter assigned to app.state app.state.limiter = auth_limiter # existing — drives SlowAPIMiddleware ``` **Middleware registration order** — CorrelationIDMiddleware added last (runs first), per existing Starlette convention documented at line 113–116: ```python # After all existing app.add_middleware() calls, add last: app.add_middleware(CorrelationIDMiddleware) ``` --- ### `backend/api/auth.py` (controller, request-response) — D-11, D-13 **Change type:** Replace `key_func=get_remote_address` with `key_func=get_client_ip`. Two-line change. **Current limiter declaration** (`backend/api/auth.py` lines 37–44): ```python from slowapi import Limiter from slowapi.util import get_remote_address router = APIRouter(prefix="/api/auth", tags=["auth"]) # IP-level rate limiter (SEC-02 — 10 req/min on register/login/refresh) limiter = Limiter(key_func=get_remote_address) ``` **Replacement**: ```python from slowapi import Limiter from deps.utils import get_client_ip # replace get_remote_address import router = APIRouter(prefix="/api/auth", tags=["auth"]) # IP-level rate limiter with trusted-proxy key function (D-11, D-13) limiter = Limiter(key_func=get_client_ip) ``` **Existing `@limiter.limit()` decorators remain unchanged** — they are already on the right endpoints (`lines 97–98`, `170–171`, `300–301`, `538–539`, `621–622`). Per D-13 the limits themselves (10/minute, 5/hour) are preserved. --- ### `backend/api/documents.py` (controller, CRUD) — D-12 **Change type:** Add `@account_limiter.limit("100/minute")` decorator and `request.state.current_user = current_user` binding to authenticated endpoints. **Existing endpoint pattern** (`backend/api/documents.py` lines 88–101) — the handler signature and dependency injection to copy from: ```python @router.post("/upload-url") async def request_upload_url( body: UploadUrlRequest, session: AsyncSession = Depends(get_db), current_user: User = Depends(get_regular_user), ): ``` **New pattern with per-account rate limiting**: ```python from backend.services.rate_limiting import account_limiter # shared module @router.get("/") @account_limiter.limit("100/minute") async def list_documents( request: Request, # Request must be first positional arg for slowapi current_user: User = Depends(get_regular_user), session: AsyncSession = Depends(get_db), ... ): request.state.current_user = current_user # MUST be first line — exposes user to key_func structlog.contextvars.bind_contextvars(user_id=str(current_user.id)) ... ``` **Key constraint:** `Request` must appear as the first parameter after `self` for slowapi decorators to work. Review each existing endpoint signature — `request: Request` may need to be added or moved to first position. --- ### `backend/api/cloud.py` (controller, request-response) — D-12 **Change type:** Same per-account rate limiting pattern as `documents.py`. The cloud router uses the same `Depends(get_regular_user)` pattern visible at `backend/api/cloud.py` lines 29–47. **Existing endpoint signature pattern** (`backend/api/cloud.py` lines 44–47): ```python from deps.auth import get_regular_user from deps.db import get_db router = APIRouter(prefix="/api/cloud", tags=["cloud"]) ``` **Apply the same decorator/binding pattern** as `documents.py` above to each endpoint that uses `Depends(get_regular_user)`. --- ### `backend/services/logging.py` (service, event-driven) — D-01 **Change type:** New file. No existing analog for a structlog setup module. The closest structural analog is `backend/services/auth.py` (pure Python service, no FastAPI coupling, single module with module-level init). **Analog structure** (`backend/services/auth.py` lines 1–45): ```python """ Auth service — pure Python, no FastAPI coupling. ... """ from __future__ import annotations import logging # ... imports ... from config import settings logger = logging.getLogger(__name__) # Module-level init (PasswordHash instance) _pwd = PasswordHash([Argon2Hasher()]) def hash_password(plain: str) -> str: ... def verify_password(plain: str, hashed: str) -> bool: ... ``` **New file pattern** — mirror the module-level docstring, `from __future__ import annotations`, import from `config.settings`, expose a single entry-point function: ```python """ Structured logging setup — pure Python, no FastAPI coupling. Call setup_logging() once in main.py lifespan before the yield. Bridges stdlib loggers (uvicorn, sqlalchemy, celery) through the same structlog JSON processor chain. """ from __future__ import annotations import logging import structlog from config import settings def setup_logging(json_logs: bool = False, log_level: str = "INFO") -> None: """Configure structlog with ProcessorFormatter bridge for stdlib loggers. Parameters match settings.log_json and settings.log_level so callers can pass settings values directly. """ timestamper = structlog.processors.TimeStamper(fmt="iso") shared_processors = [ structlog.contextvars.merge_contextvars, # MUST be first structlog.stdlib.add_log_level, structlog.stdlib.add_logger_name, structlog.stdlib.PositionalArgumentsFormatter(), structlog.stdlib.ExtraAdder(), timestamper, structlog.processors.StackInfoRenderer(), ] if json_logs: shared_processors.append(structlog.processors.format_exc_info) structlog.configure( processors=shared_processors + [ structlog.stdlib.ProcessorFormatter.wrap_for_formatter, ], logger_factory=structlog.stdlib.LoggerFactory(), cache_logger_on_first_use=True, ) log_renderer = ( structlog.processors.JSONRenderer() if json_logs else structlog.dev.ConsoleRenderer() ) formatter = structlog.stdlib.ProcessorFormatter( foreign_pre_chain=shared_processors, processors=[ structlog.stdlib.ProcessorFormatter.remove_processors_meta, log_renderer, ], ) handler = logging.StreamHandler() handler.setFormatter(formatter) root_logger = logging.getLogger() root_logger.addHandler(handler) root_logger.setLevel(log_level.upper()) # Route uvicorn logs through structlog; suppress access log (re-emitted by middleware) for name in ("uvicorn", "uvicorn.error"): logging.getLogger(name).handlers.clear() logging.getLogger(name).propagate = True logging.getLogger("uvicorn.access").handlers.clear() logging.getLogger("uvicorn.access").propagate = False ``` --- ### `backend/config.py` (config) — D-01, D-11 **Change type:** Add three new settings fields to the existing `Settings` class. Pattern: follow the existing field declaration style (`backend/config.py` lines 1–74) — typed field with default, grouped by phase/feature with a comment. **Existing field pattern** (`backend/config.py` lines 48–71): ```python # AI classification defaults (Phase 3 — D-13, D-15) system_prompt: str = "" default_ai_provider: str = "ollama" default_ai_model: str = "llama3.2" # Cloud Storage (Phase 5) cloud_creds_key: str = "CHANGEME-32-bytes-padded!!" google_client_id: str = "" ``` **New fields to append** (after the Cloud Storage block, before `settings = Settings()`): ```python # Observability (Phase 6 — D-01) log_level: str = "INFO" # LOG_LEVEL env var; passed to setup_logging() log_json: bool = False # LOG_JSON env var; True in production # Rate limiting (Phase 6 — D-11) # Comma-separated list of trusted proxy CIDRs; requests from these may set X-Forwarded-For trusted_proxy_cidrs: list[str] = [ "127.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "::1/128", ] ``` **env_list_separator** — already set to `","` in `model_config` (line 11), so `TRUSTED_PROXY_CIDRS=127.0.0.0/8,172.16.0.0/12` is parsed correctly out of the box. --- ### `backend/load_tests/locustfile.py` (test, request-response) — D-04, D-05, D-06 **Change type:** New file in new directory `backend/load_tests/`. No existing Locust file. Closest analog is the auth fixture pattern in `backend/tests/conftest.py`. **Auth pattern from conftest.py** — the login flow the Locust `on_start()` replicates (`backend/tests/conftest.py` lines 186–226): ```python # auth_user fixture shows the login payload shape: token = create_access_token(str(user_id), "user") headers = {"Authorization": f"Bearer {token}"} # Locust replicates the same via HTTP POST: resp = self.client.post( "/api/auth/login", json={"email": TEST_EMAIL, "password": TEST_PASSWORD}, ) self.access_token = resp.json().get("access_token", "") ``` **New file pattern** (from RESEARCH.md Pattern 7): ```python """Locust load test for DocuVault — D-04, D-05, D-06. Run: locust --headless --users 50 --spawn-rate 10 --run-time 5m \\ --host http://localhost:8000 \\ --csv backend/load_tests/results \\ -f backend/load_tests/locustfile.py Prerequisites: a user with TEST_EMAIL/TEST_PASSWORD must exist in the DB. Create via: POST /api/auth/register (on_start handles this — registers if not exists). """ import os from locust import HttpUser, task, between, events TEST_EMAIL = os.environ.get("LOAD_TEST_EMAIL", "loadtest@example.com") TEST_PASSWORD = os.environ.get("LOAD_TEST_PASSWORD", "Loadtest123!@#") class DocuVaultUser(HttpUser): wait_time = between(0.5, 2.0) access_token: str = "" def on_start(self): # Register if not exists (catches 409 Conflict silently) self.client.post( "/api/auth/register", json={"handle": "loadtestuser", "email": TEST_EMAIL, "password": TEST_PASSWORD}, ) resp = self.client.post( "/api/auth/login", json={"email": TEST_EMAIL, "password": TEST_PASSWORD}, ) if resp.status_code == 200: self.access_token = resp.json().get("access_token", "") else: self.environment.runner.quit() def _auth_headers(self): return {"Authorization": f"Bearer {self.access_token}"} @task(5) def list_documents(self): self.client.get("/api/documents/", headers=self._auth_headers()) @task(2) def upload_document(self): # NOTE: confirm upload endpoint shape against documents.py before finalizing # If two-step presigned flow: POST /upload-url → PUT to MinIO → POST /{id}/confirm from io import BytesIO data = b"%PDF-1.4 1 0 obj<>endobj" self.client.post( "/api/documents/upload", files={"file": ("test.pdf", BytesIO(data), "application/pdf")}, headers=self._auth_headers(), ) @task(1) def refresh_token(self): self.client.post("/api/auth/refresh") @events.quitting.add_listener def check_sla(environment, **kwargs): stats = environment.runner.stats.total if stats.fail_ratio > 0.01: environment.process_exit_code = 1 elif stats.get_response_time_percentile(0.95) > 200: environment.process_exit_code = 1 elif stats.get_response_time_percentile(0.99) > 500: environment.process_exit_code = 1 ``` **Also create:** `backend/load_tests/__init__.py` (empty) so pytest does not discover this directory. **Credentials security:** TEST_EMAIL and TEST_PASSWORD read from env vars — never hardcoded in version-controlled files. --- ### `backend/Dockerfile` (config) — D-07, D-08, D-09 **Change type:** Full replacement of single-stage build with multi-stage. **Current file** (`backend/Dockerfile` lines 1–16): ```dockerfile FROM python:3.12-slim WORKDIR /app RUN apt-get update && apt-get install -y \ tesseract-ocr libgl1 libglib2.0-0 \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 ``` **Replacement pattern** (from RESEARCH.md Pattern 5, D-07): ```dockerfile # Stage 1: builder — installs Python packages as root FROM python:3.12-slim AS builder WORKDIR /build RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir --prefix=/install -r requirements.txt # Stage 2: runtime — non-root appuser, no build tools FROM python:3.12-slim AS runtime # Runtime system deps (tesseract-ocr, libgl1, libglib2.0-0 are required at runtime) RUN apt-get update && apt-get install -y --no-install-recommends \ tesseract-ocr \ libgl1 \ libglib2.0-0 \ && rm -rf /var/lib/apt/lists/* COPY --from=builder /install /usr/local RUN groupadd --gid 1000 appgroup && \ useradd --uid 1000 --gid appgroup --shell /bin/sh --no-create-home appuser WORKDIR /app COPY --chown=appuser:appgroup . . USER appuser EXPOSE 8000 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] ``` **Note on `--prefix=/install`:** Verify that `pip install --prefix=/install` followed by `COPY --from=builder /install /usr/local` correctly populates Python site-packages in the runtime stage. An alternative is `pip install --target=/install` with `PYTHONPATH` set. The prefix approach is preferred. Verify in Wave 0 smoke test: `docker run --rm docuvault-backend:latest python -c "import structlog"`. --- ### `docker-compose.yml` (config) — D-08, D-09, D-02 **Change type:** Modify existing service definitions for `backend` and `celery-worker`; add `loki`, `promtail`, `grafana` services; add named volumes. **Existing service definition pattern** (`docker-compose.yml` lines 49–80) — the `backend` service to extend: ```yaml backend: build: ./backend ports: - "8000:8000" volumes: - ./backend:/app environment: - DATABASE_URL=${DATABASE_URL} # ... (existing env vars) ... command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload depends_on: postgres: condition: service_healthy ... ``` **Hardening additions for `backend` and `celery-worker`** (D-08, D-09): ```yaml # Add these keys to both backend and celery-worker service definitions: read_only: true tmpfs: - /tmp:mode=1777 # world-writable; appuser (uid=1000) can write; covers tempfile.NamedTemporaryFile cap_drop: - ALL security_opt: - no-new-privileges:true labels: logging: "promtail" # Promtail docker_sd_configs filter label (D-02) ``` **New env vars for `backend` service** (D-01): ```yaml environment: # ... existing vars ... - LOG_LEVEL=${LOG_LEVEL:-INFO} - LOG_JSON=${LOG_JSON:-false} ``` **New services block** (D-02): ```yaml loki: image: grafana/loki:latest ports: - "3100:3100" volumes: - ./docker/loki/loki-config.yaml:/etc/loki/local-config.yaml - loki_data:/loki command: -config.file=/etc/loki/local-config.yaml promtail: image: grafana/promtail:latest volumes: - ./docker/loki/promtail-config.yaml:/etc/promtail/config.yaml - /var/lib/docker/containers:/var/lib/docker/containers:ro - /var/run/docker.sock:/var/run/docker.sock command: -config.file=/etc/promtail/config.yaml depends_on: - loki grafana: image: grafana/grafana:latest ports: - "3000:3000" environment: - GF_AUTH_ANONYMOUS_ENABLED=true - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin volumes: - grafana_data:/var/lib/grafana depends_on: - loki ``` **New volumes** (append to existing `volumes:` block): ```yaml volumes: postgres_data: # existing minio_data: # existing loki_data: # new grafana_data: # new ``` **Celery-beat exclusion:** D-08 says `read_only: true` applies to "FastAPI and Celery worker services" — the `celery-beat` service writes `celerybeat-schedule` to its working directory. Do NOT apply `read_only: true` to `celery-beat`. If hardening is desired later, add `--schedule /tmp/celerybeat-schedule` to its command. --- ## Shared Patterns ### Existing Limiter declaration (all auth endpoint rate limiting) **Source:** `backend/api/auth.py` lines 37–44 **Apply to:** `backend/api/auth.py` (replace `get_remote_address` with `get_client_ip`) ```python # Current (to be replaced): from slowapi.util import get_remote_address limiter = Limiter(key_func=get_remote_address) # Replacement: from deps.utils import get_client_ip limiter = Limiter(key_func=get_client_ip) ``` ### Per-account rate limiter (second Limiter instance) **Source:** RESEARCH.md Pattern 4 **Apply to:** `backend/api/documents.py`, `backend/api/cloud.py` **Where to define it:** A shared module — `backend/api/rate_limiting.py` or `backend/main.py` — so both document and cloud routers import the same instance. ```python from slowapi import Limiter from fastapi import Request def _account_key(request: Request) -> str: user = getattr(request.state, "current_user", None) if user is None: return request.client.host if request.client else "anonymous" return str(user.id) account_limiter = Limiter(key_func=_account_key) ``` ```python # Usage in each authenticated endpoint: @router.get("/") @account_limiter.limit("100/minute") async def list_documents( request: Request, # must be first positional param current_user: User = Depends(get_regular_user), ... ): request.state.current_user = current_user # expose to key_func — MUST be first line structlog.contextvars.bind_contextvars(user_id=str(current_user.id)) ... ``` ### Structlog logger usage in route handlers **Source:** RESEARCH.md Code Examples section **Apply to:** `backend/api/documents.py`, `backend/api/cloud.py`, any router that has authenticated endpoints ```python import structlog log = structlog.get_logger() async def some_endpoint(..., current_user: User = Depends(get_regular_user)): structlog.contextvars.bind_contextvars(user_id=str(current_user.id)) log.info("event.name", field=value) ``` ### Pydantic Settings field pattern **Source:** `backend/config.py` lines 13–72 **Apply to:** All new env vars in `backend/config.py` ```python # Pattern: typed field + default value + inline comment with phase reference field_name: type = default_value # ENV_VAR_NAME env var; description (Phase N — Decision ref) ``` ### Async test client with auth headers **Source:** `backend/tests/conftest.py` lines 186–226 **Apply to:** `backend/tests/test_logging.py`, `backend/tests/test_rate_limiting.py` ```python @pytest_asyncio.fixture async def auth_user(db_session: AsyncSession): # Returns: {"user": User, "token": str, "headers": {"Authorization": "Bearer "}} ... # Usage in test: async def test_something(async_client, auth_user): resp = await async_client.get("/api/documents/", headers=auth_user["headers"]) assert resp.status_code == 200 ``` --- ## No Analog Found Files with no close match in the codebase (planner should use RESEARCH.md patterns directly): | File | Role | Data Flow | Reason | |------|------|-----------|--------| | `docker/loki/loki-config.yaml` | config | — | No YAML service configs exist in the repo; use RESEARCH.md Pattern 6 (loki-config.yaml section) verbatim | | `docker/loki/promtail-config.yaml` | config | — | No YAML service configs exist in the repo; use RESEARCH.md Pattern 6 (promtail-config.yaml section) verbatim | | `RUNBOOK.md` | documentation | — | No operational runbook exists; D-14 describes content fully | --- ## Critical Notes for Planner 1. **`get_client_ip` is a body replacement, not a new function.** CLAUDE.md mandates one canonical definition in `deps/utils.py`. Every router already imports it by name — the import chain stays intact. 2. **`CorrelationIDMiddleware` must use raw ASGI, not `BaseHTTPMiddleware`.** The existing `SecurityHeadersMiddleware` and `OriginValidationMiddleware` use `BaseHTTPMiddleware` — do not copy that pattern for `CorrelationIDMiddleware`. See RESEARCH.md Anti-Patterns section. 3. **Locust must NOT be in `requirements.txt`.** It is a dev/external tool. Add to `requirements-dev.txt` or run from a host virtualenv. The locustfile has zero imports from the application codebase. 4. **`read_only: true` excludes `celery-beat`** (Pitfall 7 in RESEARCH.md). The D-08 scope is "FastAPI and Celery worker services" only. 5. **`tmpfs: - /tmp:mode=1777`** is required (not just `/tmp`). Without `mode=1777`, appuser (uid=1000) cannot write to the tmpfs-mounted `/tmp` — `services/extractor.py` uses `tempfile.NamedTemporaryFile()` which writes to `/tmp`. 6. **Wave 0 assumption to verify:** `request.state.current_user` set as first line of handler body must be read correctly by slowapi's `key_func` before it increments the counter. Write a unit test (`test_rate_limiting.py::test_account_limiter_key`) before applying the decorator to all endpoints. --- ## Metadata **Analog search scope:** `backend/api/`, `backend/services/`, `backend/deps/`, `backend/tests/`, `docker-compose.yml`, `backend/Dockerfile`, `backend/config.py` **Files scanned:** 13 **Pattern extraction date:** 2026-06-02