Legal · LaunchDocs

Compliance Playbook

Version 1.0 · Effective 26 June 2026

LaunchDocs Compliance Playbook

Version 1.0 · Effective 26 June 2026 Owner: Data Protection Lead — legal@launchdocs.ai Audience: government / enterprise procurement reviewers running a pre-signature DPA audit, internal operators handling a compliance request, and the on-call engineer during an incident. Status: every claim in this document maps to a verified codepath or test. If a claim drifts from the code, the linked test fails.

This is the operational companion to:


0. How to use this playbook

| If a reviewer asks… | Jump to | |---|---| | "Where is the EU data actually stored?" | §1.1 | | "Show me you don't send EU data to Anthropic" | §1.2 | | "Prove your audit log can't be edited" | §3.2 | | "Demonstrate a 72-hour DSAR turnaround" | §4.1 | | "Walk me through a breach" | §5 | | "Where's your incident playbook?" | /app/docs/incident-response-procedure.md | | "Run the verification yourself" | §7 |

The DPA's Section 2 sub-processor list, Section 4 security measures, Section 6 breach window, and Section 9 retention rules are the external-facing commitments. Everything in this playbook either proves we honour them or names the exact gap if we don't yet.


1. Data Residency — what runs where

1.1 MongoDB (Frankfurt, Atlas eu-central-1)

Claim (DPA §2): EU Controllers' Personal Data is stored at MongoDB Atlas eu-central-1 (Frankfurt).

Implementation:

  • Connection string lives in MONGO_URL env var on the EU pod.
  • Read at /app/backend/db.py:11-12.
  • No fallback default — missing env var fails the pod at boot (intentional fail-fast).

Operator verification (one-shot, production):

# On the EU pod's Emergent console, dump the Atlas cluster URL:
echo "$MONGO_URL" | sed -E 's|(mongodb\+srv://)[^@]+@|\1***@|'
# Then in the Atlas UI, confirm the cluster's region is
# eu-central-1.

Auditable artefacts the Controller can request:

  • Atlas cluster region screenshot (from the Atlas Project Settings page).
  • Atlas backup policy screenshot (Continuous Cloud Backup + 7-day retention for the M10 EU cluster).
  • This playbook + the source line in db.py.

⚠️ Limit of code-level proof: we read MONGO_URL from env. The env value itself is configured at the Emergent platform level. Final proof requires the Atlas screenshot, which the Data Protection Lead keeps in the compliance evidence pack.

1.2 EU orgs route to Mistral only

Claim (DPA §2.1 + §8.1): EU Controllers' AI inference runs on Mistral AI within the EEA. Anthropic/Claude is never used for EU orgs.

Implementation:

Production env requirement: the EU pod MUST be deployed with EU_COMPLIANCE_MODE=true. The Data Protection Lead verifies this quarterly via /api/admin/eu-readiness (or by inspecting the pod's env in the Emergent console).

Operator verification:

# 1. Confirm the kill-switch is live in the running pod
curl -sX GET https://launchdocs.ai/api/eu-readiness | jq .

# 2. Pick any EU org's recent LLM call and verify the provider field
#    (admin token required)
curl -sX GET "https://launchdocs.ai/api/admin/llm-audit?org_id=<ORG>&limit=10" \
     -H "Authorization: Bearer $ADMIN_TOKEN" | jq '.events[].provider'
# Expected output: every row says "mistral", none say "anthropic"

1.3 Stripe billing data only — never procurement content

Claim (DPA §2.7): Stripe processes billing data only. RFP content, vendor evaluations, and document content are never transmitted.

Implementation: every stripe.checkout.Session.create call in the codebase lives in two files (/app/backend/billing_routes.py and /app/backend/bundle_pricing.py) and only sends: customer_email, metadata.user_id, metadata.tier, metadata.cadence, metadata.products (slug list like "docs,compliance,procure"), price IDs.

Proof:

# Greppable proof — every stripe call uses these fields only.
grep -n "stripe\.checkout\|stripe\.Customer" /app/backend/*.py

2. Sub-processors — verified list

| Sub-processor | Purpose | Location | Verified by | Notes | |---|---|---|---|---| | Mistral AI (SAS, FR, SIREN 952 418 325) | LLM inference for EU orgs | Microsoft Azure, Sweden + Norway (EEA) | llm_router.py + eu_compliance.py | API customer opt-out from model training is default per Mistral DPA §2.3 | | MongoDB Atlas | Primary data store | Frankfurt, AWS eu-central-1 (EU pod) | MONGO_URL env on EU pod + Atlas console | M10+ cluster, AES-256 at rest | | Anthropic (US only) | LLM inference for US orgs | AWS us-west-2 (US) | llm_router.py | Not used by EU orgs — see §1.2 | | Emergent platform | Application hosting | Per Emergent platform contract | Emergent platform terms | Owns the FastAPI pods, Cloudflare ingress, MongoDB Atlas integration | | Resend (Plus Five Five, Inc.) | Transactional email | Ireland (EEA) | /app/backend/email_utils.py:86resend.Emails.send | API key in RESEND_API_KEY env | | Stripe Inc. | Billing only | United States | See §1.3 | SCC-covered transfer; billing data only, no procurement content | | Cloudflare | TLS termination + WAF | Global (TLS terminates at edge nearest user) | Via Emergent platform | TLS 1.2+ enforced at the edge | | LaunchDocs application authentication | Custom JWT + bcrypt | Inside LaunchDocs pods (no third-party identity provider) | /app/backend/auth_deps.pybcrypt + PyJWT | Iter128 honesty pass replaced the prior "Ory + Kong" listing — those were copy-pasted from Mistral's own sub-processors by mistake. We don't use them. |

Sub-processor change protocol (DPA §2 commitment):

  1. Data Protection Lead notifies all Controllers no less than 30 days in advance via Resend from legal@launchdocs.ai.
  2. Controllers may object in writing during the 30-day window.
  3. Update this table AND the DPA §2 list, bump DPA_VERSION in /app/backend/.env and /app/backend/region_routes.py.
  4. EU Controllers will be re-prompted to acknowledge the new DPA on next login (the DPAGate modal re-fires when the user's dpa_version doesn't match the server's current_dpa_version).

3. Security Measures — what's enforced and where

3.1 Encryption + transport

| Control | Where enforced | Audit artefact | |---|---|---| | TLS 1.2+ in transit | Cloudflare ingress | Cloudflare SSL/TLS settings screenshot | | AES-256 at rest | MongoDB Atlas (cluster tier ≥ M10) | Atlas Security Configuration screenshot | | HTTPS-only cookies | Production FastAPI app | Set on every Set-Cookie header (verify with curl -I https://launchdocs.ai/) |

3.2 Tamper-evident audit trail (hash chain)

Claim (DPA §4 + §8): Audit logging of security-relevant events, AI processing calls, score overrides, exports, and account lifecycle actions. Persistent audit trail retained for 6 years.

Note on "tamper-evident" wording: the DPA §8 line was walked back from "tamper-evident" to "persistent, retained for 6 years" in the iter128 honesty pass. The iter129 hash chain (below) now provides the cryptographic primitive that would let us restore "tamper-evident", but we deliberately have NOT done so in the public DPA until the backfill question (see §11 Open Items) is resolved.

Implementation:

  • Every audit row written by /app/backend/security.py:audit_log carries two fields:
    • prev_hash — the row_hash of the immediately-preceding row (or "0" * 64 genesis sentinel for the first row).
    • row_hashsha256(prev_hash || canonical_json(row_body)).
  • Per-pod asyncio.Lock (_AUDIT_CHAIN_LOCK) serialises the find-latest-then-insert pair so concurrent writes don't race.
  • 6-year TTL on security_events.at_dt via /app/backend/audit_indexes.py:33.
  • Walker function: security.py:verify_audit_chain(limit=N).
  • Admin endpoint: GET /api/admin/audit-verify{verified, total, ok, first_break_at, first_break_id, first_break_reason, limit}.

Operator verification:

# Walk the last 5000 audit rows and confirm the chain
curl -sX GET "https://launchdocs.ai/api/admin/audit-verify?limit=5000" \
     -H "Authorization: Bearer $ADMIN_TOKEN" | jq .

Expected response on a fully-chained dataset: { "verified": 5000, "total": 5000, "ok": true, ... }.

Pre-iter129 rows lack the hash fields; the verifier surfaces them with reason string "row missing hash fields (pre-iter129 legacy row)" so a reviewer can distinguish legacy from tampered.

3.3 Audit coverage — what we log

| Event class | Examples | Collection | |---|---|---| | Auth | auth.login_succeeded, auth.login_failed, auth.lockout | security_events | | Account lifecycle | account.created, account.deleted, account.password_reset | security_events | | Role | role.changed, mfa.enabled, mfa.disabled | security_events | | Project | project.deleted, project.exported | security_events | | Org compliance | dpa.accepted, compliance.updated | security_events | | Upload security | upload.executable_blocked, webhook.ssrf_blocked | security_events | | LLM calls | every provider call (mistral/anthropic) with prompt-hash, model, tokens | llm_audit | | Evaluation overrides | every cell-score override (AI vs human) | evaluation_audit | | Category audit | every governance-policy edit | category_audit | | Generation audit | every AI-assisted document generation | generation_audit_log |

Note (DPA §4 honest scope): Read operations are NOT individually audited. The DPA was tightened in iter128 to remove the "comprehensive…all data processing activities" overclaim.

3.4 Human approval gate (EU AI Act Art. 14)

Claim (DPA §4.6 + §8.2): All AI-generated outputs require human review before export. No autonomous procurement decisions.

Implementation:

Operator verification: try to export an unsigned award memo through the API:

curl -sX POST "https://launchdocs.ai/api/awards/<id>/export.pdf" \
     -H "Authorization: Bearer $USER_TOKEN" | jq .
# Expected: HTTP 400, "Export blocked — EU AI Act Art. 14…"

3.5 AI-assisted disclosure on every exported PDF

Claim (DPA §8.3): All exported documents are clearly marked as AI-assisted.

Implementation: every exporter renders the footer on every page via the shared /app/backend/ai_disclosure_footer.py module. Wired into:

  • contract_exporter.py (since iter124)
  • award_exporter.py (since iter128)
  • procure_pdf_exporter.py (since iter128)
  • procure_proposal_exporter.py (since iter128)
  • rfp_builder_exporter.py (since iter128)
  • pdf_export_service.py (WeasyPrint, since iter128)

Footer wording:

  • EU: Generated with AI assistance by LaunchDocs EU | Mistral AI (Microsoft Azure, EEA) | Reviewed and approved by [USER NAME] on [DATE] | EU AI Act compliant
  • EU fallback (when reviewer not stamped): …All outputs reviewed and approved by a human before export | EU AI Act compliant
  • US: Generated with AI assistance by LaunchDocs | Human review required before use in formal decisions

Bump these strings only with a DPA_VERSION bump — both backend constant and /legal/dpa Section 8 wording must move together.

3.6 Role-based access control (current scope)

DPA §4 wording uses "role-based access at organization level" — honest scope:

Granular sub-roles ("viewer", "reviewer", "approver") are on the roadmap but NOT currently implemented. If a Controller's procurement team needs distinct drafter/approver roles, they should be aware that today both roles map to member.


4. Data Subject Rights — operator runbooks

4.1 Data Subject Access Request (DSAR)

Claim (DPA §5): Data extracts within 72 hours of written request. Direct DSARs forwarded to the Controller within 24 hours.

Runbook — Controller-initiated DSAR:

  1. Controller emails legal@launchdocs.ai citing the data subject and the records requested (org-id, project-id, evaluation-id, etc.).
  2. Data Protection Lead logs the request in the incident journal (/app/docs/incidents/dsar-YYYY-MM-DD-<slug>.md).
  3. For "give me everything" requests: the Controller's org admin should use the self-service data export at POST /api/auth/data-export — produces a JSON ZIP within minutes. No support ticket needed.
  4. For granular requests ("give me everything about this person"): the Data Protection Lead runs the export query in Mongo, packages the JSON, and delivers via Resend within 72 hours.

Runbook — Direct DSAR (data subject contacts LaunchDocs directly, not the Controller):

  1. Acknowledge receipt within 24 hours via legal@launchdocs.ai.
  2. Identify the Controller(s) holding the data subject's records (search users collection or organizations.members).
  3. Forward the DSAR to each Controller's registered contact email within 24 hours of receipt (per DPA §5 commitment).
  4. Do NOT respond directly to the data subject's substantive claim without explicit written instruction from the Controller.
  5. Log the forwarding in the incident journal.

4.2 Account / data deletion

Claim (DPA §5 + §9): Delete on instruction. Production-system deletion within 30 days; Atlas backups roll off per default schedule.

Implementation:

  • DELETE /api/auth/account/app/backend/account_lifecycle_routes.py:delete_account cancels Stripe sub + removes all Mongo rows tagged to the user.
  • Atlas backups: 7-day continuous + monthly snapshots (DPA §9). No application-level integration to purge backups individually.

4.3 Self-service data export (GDPR Art. 20 portability)

POST /api/auth/data-export (authenticated) builds a ZIP of everything the org has uploaded + generated. Synchronous for small orgs; job-status endpoint for large orgs. Available indefinitely during active subscription and during the 30-day post-cancellation window (DPA §13).


5. Breach response — the 30-minute version

Full procedure lives at /app/docs/incident-response-procedure.md. This is the abbreviated card you keep in your wallet.

| Step | Action | Target time | |---|---|---| | 1 | Open incident journal at /app/docs/incidents/<date>-<slug>.md | Now | | 2 | Classify (§4 of the procedure): Personal Data affected? | < 30 min | | 3 | Contain — rotate JWT_SECRET / API keys / lock accounts | < 1 hr | | 4 | Identify affected Controllers from security_events + llm_audit | < 2 hr | | 5 | Notify Controllers via Resend using the §6 template | < 48 hr | | 6 | If high-risk per GDPR Art. 33: notify Irish DPC at info@dataprotection.ie | < 72 hr | | 7 | Snapshot the audit chain BEFORE rotating any keys (/admin/audit-verify) | Before §3 | | 8 | Post-incident review within 30 days (§9 of the procedure) | < 30 days |

Notification window: the DPA commits to 48 hours (DPA §6 iter128 honesty pass), well inside GDPR Art. 33's 72-hour limit. This is the credible window given we don't yet operate a managed log-shipping + alerting pipeline. Tightening to 24h is on the roadmap once monitoring lands.


6. Data retention

| Class | Active | Pre-deletion warnings | Hard-delete | |---|---|---|---| | Inactive trial user | last activity < 90d | 90d email + 30d email + 14d email (legacy alarm) | 180 days after last activity | | Cancelled paid subscription | post-cancellation | n/a (subscription ended is itself the notice) | 30 days after cancellation | | security_events audit log | always | n/a | 6-year TTL | | llm_audit calls | always | n/a | 2-year TTL | | Atlas backups | always | n/a | per Atlas default (7-day continuous + monthly snapshots) |

Implementation: /app/backend/retention_service.py runs run_data_retention() once every 24 hours, wired in at /app/backend/startup_hooks.py:432-437. Stage tracking lives at users.warning_emails_sent_at = {first: iso, final: iso, legacy14: iso}.

Configurable per-org retention: NOT implemented today. The DPA §9 flags this as a Q3 2026 roadmap item.


7. Self-service verification for reviewers

A reviewer can perform every check below from a curl prompt with no LaunchDocs employee in the loop.

7.1 DPA text matches the codebase

curl -s https://launchdocs.ai/legal/dpa | grep -E "Version|48 hours|Mistral|production systems"

Expected matches: Version 1.2, no later than 48 hours, Mistral AI, production systems within 30 days.

7.2 Audit chain integrity

# Get an admin token
TOKEN=$(curl -sX POST https://launchdocs.ai/api/admin/login \
        -H "Content-Type: application/json" \
        -d '{"email":"<admin>","password":"<pw>"}' | jq -r .token)

# Verify the chain
curl -sX GET "https://launchdocs.ai/api/admin/audit-verify?limit=5000" \
     -H "Authorization: Bearer $TOKEN" | jq .

7.3 EU compliance kill-switch

curl -s https://launchdocs.ai/api/eu-readiness | jq .

Expected: {"compliance_mode": true, "provider": "mistral", ...} on the EU production pod.

7.4 Run the in-tree compliance test suite

cd /app/backend
python -m pytest tests/test_iter125_signup_flow.py \
                 tests/test_iter127_bundle_dpa.py \
                 tests/test_iter128_dpa_honesty_pass.py \
                 tests/test_iter129_audit_chain.py -v

Expected: 53/53 passed. Every test in those files maps a DPA claim to a code assertion — a passing run is the codebase's self-attestation against the DPA.


8. Evidence pack — what to keep on file

Maintained by the Data Protection Lead, refreshed at every DPA version bump:

  1. Screenshot: MongoDB Atlas cluster region = eu-central-1.
  2. Screenshot: Atlas encryption at rest = AES-256.
  3. Screenshot: Atlas backup policy (7-day continuous + monthly).
  4. Screenshot: Cloudflare SSL/TLS minimum version = TLS 1.2.
  5. Mistral DPA + sub-processor list (from legal.mistral.ai).
  6. Resend DPA.
  7. Stripe DPA (billing data scope confirmation).
  8. This playbook + the iter128/iter129 test report (latest /app/test_reports/iteration_*.json).
  9. The active version of /app/docs/incident-response-procedure.md.

Note on items 1–4. The Atlas and Cloudflare screenshots are maintained by the Data Protection Lead and available on request from legal@launchdocs.ai. They are not published in this repository to avoid stale screenshots as infrastructure configurations are updated; the canonical source of truth lives in the respective consoles and is verified at every DPA version bump.


9. Test coverage matrix — DPA claim ↔ pytest

Every column below is grep-able against /app/backend/tests/test_iter12*.

| DPA § | Claim | Pytest verifies it | |---|---|---| | §2 | Ory + Kong removed | test_legal_dpa_jsx_removed_ory_and_kong | | §2 | Custom JWT replacement listed | same | | §2.1 | EU → Mistral routing | test_iter125_signup_flow.test_eu_signup_* | | §2.7 | Stripe billing only | code review (see §1.3) | | §4 | Audit logging scope narrowed | test_legal_dpa_jsx_audit_scope_narrowed | | §4 | Hash chain on audit log | test_audit_log_writes_chained_rows, test_verify_audit_chain_detects_row_hash_tampering | | §4.6 | Human approval gate | award_routes._require_reviewed (manual + admin endpoint) | | §5 | DSAR forwarding line | test_legal_dpa_jsx_dsar_forwarding_line_added | | §6 | 48-hour breach window | test_legal_dpa_jsx_breach_window_walked_back_to_48h, test_dpa_modal_short_summary_matches_walked_back_48h_breach_window | | §6 | Modal & full DPA consistent | same | | §8.3 | AI disclosure on every exporter | test_every_reportlab_exporter_imports_ai_disclosure_footer, test_award_exporter_uses_reportlab_footer_callback, test_rfp_builder_exporter_uses_reportlab_footer_callback | | §8.3 | EU footer wording verbatim | test_ai_disclosure_footer_eu_text_is_exact_iter128_wording, test_footer_eu_with_reviewer_and_date_renders_per_spec | | §8.3 | US footer doesn't leak EU claim | test_footer_us_ignores_reviewer_fields | | §8 | Persistent (not tamper-evident) wording | test_legal_dpa_jsx_tamper_evident_replaced | | §9 | Backup deletion narrowed | test_legal_dpa_jsx_backup_deletion_narrowed | | §9 | 90d + 30d retention warnings | test_retention_warnings_have_90d_and_30d_lead_constants | | §9 | Retention scheduler wired | test_retention_scheduler_wired_into_startup_hooks | | n/a | Incident procedure exists | test_incident_response_procedure_document_exists, test_incident_response_procedure_exists_with_required_sections | | n/a | DPA version bumped to 1.2 / 2026.06.25 | test_legal_dpa_jsx_version_label_bumped, test_dpa_modal_version_label_bumped, test_dpa_version_bumped_to_iter128_value | | n/a | Demo account fully unlocked | test_demo_account_has_all_three_visible_products |

Suite total: 53/53 passing as of 26 June 2026.


10. Maintenance & review cadence

| When | Action | |---|---| | Every 6 months (or at any DPA version bump) | Data Protection Lead reviews this playbook end-to-end and refreshes screenshots in the evidence pack | | At every sub-processor change | Update §2 table here AND DPA §2 AND DPA_VERSION constant | | After every incident | Cross-reference incident journal against §5 — if the procedure had to be deviated from, update both this playbook and /app/docs/incident-response-procedure.md | | When a new exporter is added | Wire the AI disclosure footer (§3.5) AND extend the test in test_every_reportlab_exporter_imports_ai_disclosure_footer | | When a new audit event class is added | Add to §3.3 table AND register the event_type in security.AUDIT_EVENT_TYPES |


11. Open Items (honest gaps)

These are real gaps. Customers expecting a competing EU procurement platform's level of maturity should know about them.

| Item | Impact | Status | |---|---|---| | EU production pod not yet live | All EU compliance architecture is built and tested but the EU deployment is pending written confirmation of EU hosting region from our infrastructure provider. Until the EU pod is live, EU_COMPLIANCE_MODE=true cannot be verified in production. | Blocked on hosting provider confirmation — expected within 30 days | | Centralised log shipping + alerting (Sentry / Datadog / SIEM) | Breach window can't credibly tighten below 48h without it | Roadmap | | Pre-iter129 audit rows lack hash chain | /admin/audit-verify currently returns ok=false until backfilled or skipped | Backfill plan TBD (~30 lines, see iter129 finish) | | Configurable per-org retention | DPA §9 promises Q3 2026 | Roadmap | | Granular RBAC roles (viewer/reviewer/approver) | Today both map to member | Roadmap | | Atlas backup-purge integration on account deletion | DPA §9 narrowed to "production systems within 30 days; Atlas backups roll off per default" — currently honest, not aspirational | Aspirational | | Region migration runbook (US ↔ EU tenant move) | No documented playbook for moving an org between residencies | Roadmap | | EU pod EU_COMPLIANCE_MODE=true env confirmation | Without this env var the kill-switch in §1.2 is a no-op | Quarterly verification by Data Protection Lead |


Maintained by the Data Protection Lead. Next scheduled review: 25 December 2026. Questions: legal@launchdocs.ai