---
phase: 06-performance-production-hardening
plan: 03
type: execute
wave: 1
depends_on:
  - 06-01
files_modified:
  - backend/requirements-dev.txt
  - backend/load_tests/locustfile.py
  - backend/load_tests/README.md
autonomous: false
requirements:
  - D-04
  - D-05
  - D-06
user_setup:
  - service: locust
    why: "Load testing runs OUTSIDE the production Docker image; install on the host or in a dedicated venv"
    env_vars:
      - name: LOAD_TEST_EMAIL
        source: "Local .env or shell export — defaults to loadtest@example.com"
      - name: LOAD_TEST_PASSWORD
        source: "Local .env or shell export — defaults to a fixed value matching AUTH-01 strength rules"

must_haves:
  truths:
    - "locust is installable from requirements-dev.txt and is NOT installed by the production Dockerfile"
    - "locustfile.py exposes a DocuVaultUser HttpUser class with login → list → upload tasks weighted realistically"
    - "Running `locust --headless --users 50 --spawn-rate 10 --run-time 5m` against a running stack exits 0 only when p95 < 200ms AND p99 < 500ms AND fail_ratio ≤ 1%"
    - "Locust on_start is self-bootstrapping — calls /api/auth/register first (409 conflict ignored), then /api/auth/login"
    - "The upload task path matches the actual documents.py shape (presigned upload-url → PUT → confirm OR direct /upload — confirmed against documents.py before commit)"
  artifacts:
    - path: "backend/requirements-dev.txt"
      provides: "locust pin (separate from production requirements.txt)"
      contains: "locust>=2.34.0"
    - path: "backend/load_tests/locustfile.py"
      provides: "Full Locust user class + SLA gating quitting listener"
      contains: "class DocuVaultUser"
      min_lines: 80
    - path: "backend/load_tests/README.md"
      provides: "Run instructions, prerequisites, env var documentation, expected exit codes"
  key_links:
    - from: "backend/load_tests/locustfile.py on_start()"
      to: "/api/auth/register and /api/auth/login"
      via: "POST JSON {email, password, handle}"
      pattern: "auth/(register|login)"
    - from: "backend/load_tests/locustfile.py upload_document()"
      to: "documents.py upload endpoint shape (presigned vs direct)"
      via: "verified by reading backend/api/documents.py first"
      pattern: "api/documents/(upload|upload-url)"
    - from: "backend/load_tests/locustfile.py check_sla()"
      to: "environment.process_exit_code"
      via: "@events.quitting.add_listener"
      pattern: "events.quitting.add_listener"
---

<objective>
Implement the Locust load test (D-04, D-05, D-06) that satisfies the SLA targets in Phase 6 success criterion 1: 50 concurrent users for a 5-minute soak with p50 < 100ms, p95 < 200ms, p99 < 500ms, and zero endpoint failures. Promote the locustfile.py skeleton from 06-01 to a full implementation. Resolve RESEARCH.md Open Question 1 by reading documents.py and using the correct upload flow shape.

Purpose: Make Phase 6 SC-01 measurable and gate-able. Provide a single command (`locust --headless ...`) whose exit code tells the on-call engineer whether the latest deployment meets SLA.

Output: requirements-dev.txt with locust pinned, a complete locustfile.py with SLA-gating quitting listener, and a load_tests/README.md so a fresh operator can run the test without reading PLAN.md.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/phases/06-performance-production-hardening/06-CONTEXT.md
@.planning/phases/06-performance-production-hardening/06-RESEARCH.md
@.planning/phases/06-performance-production-hardening/06-PATTERNS.md
@.planning/phases/06-performance-production-hardening/06-VALIDATION.md
@CLAUDE.md
@backend/api/documents.py
@backend/api/auth.py
@backend/tests/conftest.py
@backend/load_tests/locustfile.py
@backend/load_tests/__init__.py

<interfaces>
<!-- The Locust task structure mirrors a realistic user session per D-05. -->

From backend/api/auth.py:
- POST /api/auth/register — body {handle, email, password}, returns 201 on success or 409 if email exists.
- POST /api/auth/login — body {email, password, totp_code?, backup_code?}, returns 200 with {access_token: str, ...} on success.
- POST /api/auth/refresh — relies on the httpOnly refresh_token cookie which the Locust HttpUser session maintains automatically via the requests Session it wraps.

From backend/api/documents.py (verified at planning time):
- GET /api/documents/ — list endpoint, returns array.
- POST /api/documents/upload-url — body {filename, ...}, returns {upload_url, document_id}. Two-step presigned flow.
- POST /api/documents/{id}/confirm — finalises after MinIO PUT, atomic quota UPDATE.
- POST /api/documents/upload — alternative direct multipart flow used for cloud backends (also accepts target_backend=minio). Use the DIRECT /upload endpoint for the load test because it is a single HTTP call from the client's perspective and avoids needing a MinIO presigned PUT from the Locust process.

From RESEARCH.md Pattern 7 + Open Question 1 resolution:
- Locust HttpUser maintains an HttpSession (cookies preserved across calls within one virtual user) — refresh_token cookie persists between login and refresh calls.
</interfaces>
</context>

<tasks>

<task type="checkpoint:human-verify" gate="blocking-human">
  <name>Task 1: Confirm load-test credential strategy</name>
  <read_first>
    - .planning/phases/06-performance-production-hardening/06-RESEARCH.md (Pitfall 4 — load test user not pre-created)
    - .planning/phases/06-performance-production-hardening/06-CONTEXT.md (D-04 prerequisites)
  </read_first>
  <what-built>
    Two strategies for the load-test user. The planner needs the operator to pick one before implementation.
    Option A — self-bootstrapping: locustfile on_start calls POST /api/auth/register (silently swallows 409 if user already exists), then POST /api/auth/login. Pros: zero out-of-band setup; works against a clean DB. Cons: writes a real user into the production-shape DB; password defaults to a fixed string unless LOAD_TEST_PASSWORD env var is set.
    Option B — pre-seeded user via Alembic seed: planner writes a one-off migration or makefile target to create the load-test user, locustfile only logs in. Pros: load test does not pollute the user table during runs. Cons: extra setup step; operator must run the seed before every fresh DB.
  </what-built>
  <how-to-verify>
    1. Decide: Option A (self-bootstrap, default) or Option B (pre-seeded).
    2. If Option A: confirm the load-test handle/email default — the planner uses `loadtestuser` / `loadtest@example.com` / `Loadtest123!@#` unless overridden by LOAD_TEST_EMAIL / LOAD_TEST_PASSWORD env vars. Confirm this password meets AUTH-01 strength rules: ≥12 chars, upper, lower, digit, special — "Loadtest123!@#" is 14 chars and has all classes.
    3. If Option B: provide the seeding command you want the planner to document in README.md.
  </how-to-verify>
  <acceptance_criteria>
    - User selects "A" or "B"; planner proceeds with that strategy in Task 3.
    - If A: README.md documents that the loadtest user persists in the DB and can be cleaned up with `DELETE FROM users WHERE email='loadtest@example.com'`.
    - If B: README.md documents the seeding command before-each-run.
  </acceptance_criteria>
  <resume-signal>Type "A" for self-bootstrap (default) or "B" for pre-seeded with the seeding command.</resume-signal>
</task>

<task type="auto">
  <name>Task 2: Add locust to requirements-dev.txt</name>
  <files>backend/requirements-dev.txt</files>
  <read_first>
    - backend/requirements.txt (current pins — do NOT modify this file)
    - .planning/phases/06-performance-production-hardening/06-RESEARCH.md (Standard Stack section — "Locust is a dev/test dependency only")
  </read_first>
  <action>
    Create backend/requirements-dev.txt (new file). Top of file: comment block stating "Development and load-test dependencies. NOT installed by the production Dockerfile. Install separately on the host or in a dedicated venv via: `pip install -r backend/requirements-dev.txt`." First include line: `-r requirements.txt` so the dev environment also installs everything from production. Then a `# Load testing (Phase 6 — D-04)` comment followed by `locust>=2.34.0`. Do NOT modify backend/requirements.txt — locust must NOT enter the production image (per RESEARCH.md and the gates checklist).
  </action>
  <verify>
    <automated>test -f backend/requirements-dev.txt &amp;&amp; grep -c '^locust' backend/requirements-dev.txt &amp;&amp; ! grep -q '^locust' backend/requirements.txt</automated>
  </verify>
  <acceptance_criteria>
    - File backend/requirements-dev.txt exists: `test -f backend/requirements-dev.txt` returns 0.
    - File contains `-r requirements.txt`: `grep -c '^-r requirements.txt' backend/requirements-dev.txt` returns 1.
    - File contains `locust>=2.34.0`: `grep -c '^locust>=2.34.0' backend/requirements-dev.txt` returns 1.
    - backend/requirements.txt does NOT contain locust: `grep -c '^locust' backend/requirements.txt` returns 0.
    - File parses as valid pip requirements: `pip install --dry-run -r backend/requirements-dev.txt 2>&amp;1 | grep -cE 'ERROR|error'` returns 0.
  </acceptance_criteria>
  <done>locust installable from requirements-dev.txt, production requirements.txt unchanged.</done>
</task>

<task type="auto">
  <name>Task 3: Implement locustfile.py + README.md</name>
  <files>backend/load_tests/locustfile.py, backend/load_tests/README.md</files>
  <read_first>
    - backend/api/documents.py (lines around `upload_document` and `confirm_upload` — confirm the request body shape for /upload and /upload-url + /{id}/confirm before finalising the upload task)
    - backend/api/auth.py (register/login request body shapes)
    - backend/load_tests/locustfile.py (the skeleton from 06-01 — replace bodies)
    - .planning/phases/06-performance-production-hardening/06-RESEARCH.md (Pattern 7, Pitfall 4, Open Question 1 + 2)
    - .planning/phases/06-performance-production-hardening/06-PATTERNS.md (locustfile.py section)
  </read_first>
  <action>
    Replace the body of backend/load_tests/locustfile.py with the full Pattern 7 implementation, parameterised by the credential strategy chosen in Task 1.
    Top of file docstring: name, purpose, run command (`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, SLA exit-code semantics.
    Imports: `import os`, `from io import BytesIO`, `from locust import HttpUser, task, between, events`. NO imports from backend application code.
    Constants: TEST_EMAIL = os.environ.get("LOAD_TEST_EMAIL", "loadtest@example.com"); TEST_PASSWORD = os.environ.get("LOAD_TEST_PASSWORD", "Loadtest123!@#"); TEST_HANDLE = "loadtestuser".
    Class DocuVaultUser(HttpUser): wait_time = between(0.5, 2.0); access_token: str = "".
    Method on_start(self): if strategy A — POST /api/auth/register with {handle: TEST_HANDLE, email: TEST_EMAIL, password: TEST_PASSWORD} catching 409 silently; THEN POST /api/auth/login with {email, password}; on 200 set self.access_token from response JSON access_token; on any other status call self.environment.runner.quit().
    Method _auth_headers(self): return {"Authorization": f"Bearer {self.access_token}"}.
    Tasks (weighted per D-05 realistic session):
      @task(5) list_documents(self): self.client.get("/api/documents/", headers=self._auth_headers(), name="GET /api/documents/").
      @task(2) upload_document(self): use the DIRECT /api/documents/upload endpoint with form-data (target_backend="minio", file fake PDF bytes b"%PDF-1.4 ..." in a BytesIO), headers from _auth_headers, name="POST /api/documents/upload". Confirm before committing that documents.py /upload accepts the target_backend form field.
      @task(3) get_document(self): list first to pick an id; if list is non-empty pick the first doc and GET /api/documents/{id}, name="GET /api/documents/{id}"; if empty, skip silently (do not record as failure).
      @task(1) refresh_token(self): POST /api/auth/refresh, name="POST /api/auth/refresh" — relies on HttpSession cookie jar to carry refresh_token.
    SLA listener (module-level): `@events.quitting.add_listener def check_sla(environment, **kwargs):` reads environment.runner.stats.total; if fail_ratio > 0.01 → environment.process_exit_code = 1; elif p95 > 200 → 1; elif p99 > 500 → 1.
    Create backend/load_tests/README.md documenting: purpose, install command (`pip install -r backend/requirements-dev.txt`), run command (full headless example), env vars (LOAD_TEST_EMAIL, LOAD_TEST_PASSWORD with safe defaults), SLA semantics, exit-code mapping, cleanup tip for the loadtest user, and a note that load tests run OUTSIDE the production container.
  </action>
  <verify>
    <automated>cd backend &amp;&amp; pip install -q locust &amp;&amp; python -c "import ast; ast.parse(open('load_tests/locustfile.py').read())" &amp;&amp; cd backend &amp;&amp; locust -f load_tests/locustfile.py --headless --users 1 --spawn-rate 1 --run-time 5s --host http://localhost:8000 --only-summary 2>&amp;1 | tail -5</automated>
  </verify>
  <acceptance_criteria>
    - locustfile.py is syntactically valid: `python -c "import ast; ast.parse(open('backend/load_tests/locustfile.py').read())"` exits 0.
    - `grep -c "class DocuVaultUser" backend/load_tests/locustfile.py` returns 1.
    - `grep -c "raise NotImplementedError" backend/load_tests/locustfile.py` returns 0 (all skeleton stubs replaced).
    - `grep -c "events.quitting.add_listener" backend/load_tests/locustfile.py` returns 1.
    - `grep -cE "p95|get_response_time_percentile.0\\.95" backend/load_tests/locustfile.py` returns ≥ 1.
    - `grep -cE "p99|get_response_time_percentile.0\\.99" backend/load_tests/locustfile.py` returns ≥ 1.
    - `grep -cE "^from (backend|api|services|db|deps)" backend/load_tests/locustfile.py` returns 0 (no application imports).
    - `grep -c "@task" backend/load_tests/locustfile.py` returns 4 (list, upload, get, refresh).
    - locust accepts the file without parse errors: `locust -f backend/load_tests/locustfile.py --list-commands 2>&amp;1 | grep -cE 'error|Error|invalid'` returns 0.
    - README.md exists: `test -f backend/load_tests/README.md` returns 0.
    - README.md mentions LOAD_TEST_EMAIL: `grep -c LOAD_TEST_EMAIL backend/load_tests/README.md` returns ≥ 1.
    - The actual /api/documents/upload endpoint shape was confirmed against documents.py before commit (planner records this in SUMMARY).
  </acceptance_criteria>
  <done>locustfile.py runs without parse errors, defines four weighted tasks, SLA listener gates exit code on p95/p99/fail_ratio, README.md explains how to operate it.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Locust host process → backend API | Same as any HTTP client; uses real auth tokens, exercises real rate limits |
| LOAD_TEST_PASSWORD env var → process memory | Credential lives only in env vars and the HTTP session — never written to disk by locust |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-03-01 | Information Disclosure | Load-test credentials hardcoded in version control | mitigate | TEST_EMAIL/TEST_PASSWORD read from env vars with non-sensitive defaults; README documents using a real password via LOAD_TEST_PASSWORD; .env already in .gitignore |
| T-06-03-02 | Tampering | Locust added to production Docker image | mitigate | locust placed in requirements-dev.txt only; production Dockerfile installs only requirements.txt; grep gate verifies locust is absent from requirements.txt |
| T-06-03-03 | Denial of Service | Load test against production by mistake | accept | Operator responsibility; --host flag is required for locust to run; README warns to use http://localhost:8000 or a dedicated staging URL |
| T-06-03-04 | Tampering | locustfile import of application code couples test harness to deployment | mitigate | grep gate enforces zero imports from backend/api/services/db/deps in locustfile.py |
| T-06-03-SC | Tampering | locust install from PyPI | mitigate | Package legitimacy verified in 06-01 Task 1 (blocking human checkpoint); install gated behind that approval |
</threat_model>

<verification>
- backend/requirements.txt has no `^locust` line.
- backend/requirements-dev.txt has `^locust>=2.34.0`.
- locustfile.py parses, defines DocuVaultUser, has four @task decorators, has the SLA quitting listener, and zero application imports.
- README.md exists and documents the run command, env vars, SLA semantics.
- Operator-confirmed credential strategy (A or B) recorded in SUMMARY.
</verification>

<success_criteria>
- D-04 satisfied: Locust is the load tester, file lives at backend/load_tests/locustfile.py, runnable headless.
- D-05 satisfied: Tasks cover login (on_start) → list → get → upload, weighted realistically; cloud endpoints excluded.
- D-06 satisfied: SLA listener exits non-zero when p95 > 200ms OR p99 > 500ms OR fail_ratio > 1%.
- locust kept out of production image (gates checklist item).
</success_criteria>

<output>
Create `.planning/phases/06-performance-production-hardening/06-03-SUMMARY.md` when done. Include: credential strategy chosen (A/B), the exact upload endpoint shape used (presigned three-step vs direct one-step) and why, full locustfile line count, sample dry-run output (5s run against a running backend).
</output>
