diff --git a/README.md b/README.md
index 6b1c15b383abbc59f6db1bf6b1920f3174206e4d..380f3cd45a464c3e4317a5cbe0d61d1444579129 100644
--- a/README.md
+++ b/README.md
@@ -280,3 +280,15 @@
 codes, audit events, and clone access. Read the
 [SSH repository command architectural decision record](docs/adr/0015-ssh-repository-commands.md)
 for the command and authorization rules.
+
+## Milestone 3 gate
+
+Run the complete account and authorization gate:
+
+```text
+./scripts/check-m3
+```
+
+This command tests one Web and SSH identity, account recovery, key revocation,
+sessions, repository roles, private route isolation, push policy, audit history,
+and repository creation from the Web UI and SSH.
diff --git a/scripts/check-m3 b/scripts/check-m3
new file mode 100755
index 0000000000000000000000000000000000000000..4d514a1869bde167dde5bebe944c27fca88436d7
--- /dev/null
+++ b/scripts/check-m3
@@ -1,0 +1,7 @@
+#!/bin/sh
+set -eu
+
+./scripts/check
+cargo test --locked --release --test auth --test account_lifecycle --test web_session --test repository_policy
+cargo test --locked --release --test git_push_ssh
+cargo test --locked --release --test serve
diff --git a/tests/serve.rs b/tests/serve.rs
index ba341695de23671a0741abdd8a88e4d55e0f9e4d..f85e9265842ebc607bac34d98029a0b0fb528972 100644
--- a/tests/serve.rs
+++ b/tests/serve.rs
@@ -490,6 +490,31 @@
     wait_for_listener(ssh, &mut server);
     let discovery = http_get(http, "/alice/private.git/info/refs?service=git-upload-pack");
     assert!(discovery.starts_with("HTTP/1.1 404"), "{discovery}");
+    let home = http_get(http, "/");
+    assert!(home.starts_with("HTTP/1.1 200"));
+    assert!(!home.contains("/alice/private"));
+    for route in [
+        "/alice/private",
+        "/alice/private/refs",
+        "/alice/private/atom.xml",
+        "/alice/private/rss.xml",
+        "/alice/private/search?q=serve&ref=HEAD",
+        "/alice/private/commit/main",
+        "/alice/private/diff/main/main",
+        "/alice/private/tree/main",
+        "/alice/private/tree/main/nested",
+        "/alice/private/blob/main/README.md",
+        "/alice/private/raw/main/README.md",
+        "/alice/private/blame/main/README.md",
+        "/alice/private/archive/main.tar",
+    ] {
+        let response = http_get(http, route);
+        assert!(
+            response.starts_with("HTTP/1.1 404"),
+            "route leaked: {route}"
+        );
+        assert!(!response.contains("serve fixture"), "route leaked: {route}");
+    }
 
     let ssh_command = format!(
         "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
