diff --git a/testing/README.md b/testing/README.md
new file mode 100644
index 0000000..04be637
--- /dev/null
+++ b/testing/README.md
@@ -0,0 +1,40 @@
+# Test harness (handbook §10.3 two-tier testing)
+
+One environment-agnostic suite, two targets.
+
+## Tier 1 — local Docker (every PR)
+
+```sh
+make tier1-up # build + start: gitea(seeded) + backend + web(nginx) + mailpit
+make e2e # run Playwright against http://localhost:8080
+make fe-unit # run Vitest frontend unit tests
+make tier1-down # stop + wipe volumes
+```
+
+- App (SPA + API): http://localhost:8080
+- Mailpit UI / API: http://localhost:8025
+- Gitea (disposable): http://localhost:3001
+
+The stack is hermetic and disposable — fresh SQLite + fresh seeded Gitea each
+`tier1-up`. e2e signs in via the email OTC flow, reading the code back from
+Mailpit, so no real OAuth provider is needed.
+
+**CI note:** the backend enforces a per-IP OTC request limiter (5 requests /
+300s, `backend/app/ratelimit.py`). A single `make e2e` run uses exactly one OTC
+request, so the normal "fresh `tier1-up` then one `e2e`" flow is well clear of
+it. Do not retry `make e2e` more than ~4 times in a 5-minute window against the
+same running stack, or the 6th OTC request will 429. Restarting the backend
+container (or `tier1-down`/`tier1-up`) resets the in-process limiter.
+
+## Tier 2 — PPE (deploy gate)
+
+The SAME suite, pointed at the PPE instance (once `rfc-app-ppe.` is stood
+up via flotilla — see the engineering handbook §10.1/§10.3):
+
+```sh
+cd e2e && BASE_URL=https://rfc-app-ppe. MAILSINK_URL= npm run e2e
+```
+
+PPE provides the real nginx/systemd/SQLite topology + its own isolated Gitea +
+always-pass Turnstile keys. Standing up the PPE VM is an operator task, not part
+of this repo.