diff --git a/fleetd/src/test/java/dev/ltms/fleet/rest/RestRouteInventoryTest.java b/fleetd/src/test/java/dev/ltms/fleet/rest/RestRouteInventoryTest.java
new file mode 100644
index 0000000..2b2f552
--- /dev/null
+++ b/fleetd/src/test/java/dev/ltms/fleet/rest/RestRouteInventoryTest.java
@@ -0,0 +1,121 @@
+package dev.ltms.fleet.rest;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.LinkedHashSet;
+import java.util.Locale;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * fleetd #252: the REST surface has no supported operator entry point of its own — it is the
+ * documented fallback for when the MCP mount drops (see the operator wiki's REST page), and
+ * nothing has ever checked a hand-written route list against it. Proof it drifts: the ticket
+ * itself was filed with 14 routes, and {@link FleetApp} registers 15 — {@code GET
+ * /member-credentials} (fleetd #111) shipped hours before the ticket and was already missing from
+ * its list.
+ *
+ *
This test is modelled on {@code McpContractDocTest} (fleetd #114 / CB-609), which solved the
+ * same shape of problem for the MCP tool catalogue: read the source text with a regex instead of
+ * trusting a maintained copy. Here the "doc" is an explicit inventory written directly in this
+ * test rather than a separate Markdown file, because the operator wiki page lives in a submodule
+ * a worker cannot read reliably (see {@code CLAUDE.md} → Project addendum). Keeping the expected
+ * list in the test means it still fails loudly the moment {@link FleetApp} changes, which is the
+ * property that actually matters; a human keeps the wiki page in sync using the failure message
+ * below as the diff.
+ *
+ *
It checks source text, not behaviour. It reads {@link FleetApp}'s source for {@code
+ * app.("")} registrations and does not boot a server. It cannot catch a route that is
+ * registered through some other mechanism entirely (a filter, a redirect) — only ones shaped like
+ * the {@code app.get/post/delete/put/patch(...)} calls every route here actually uses.
+ */
+class RestRouteInventoryTest {
+
+ /** Tests run with the module directory as cwd. */
+ private static final Path REST_SOURCE = Path.of("src/main/java/dev/ltms/fleet/rest/FleetApp.java");
+
+ /**
+ * The REST surface as verified against {@link FleetApp} on 2026-09-03 (fleetd #252). Update
+ * this list AND the operator wiki's REST-surface entry together whenever a route is added,
+ * removed, or renamed — never one without the other.
+ *
+ * {@code /mcp} is deliberately excluded: it is a raw Jetty {@code ServletHolder} mount
+ * (see {@code FleetApp.build()}, around the {@code modifyServletContextHandler} call), not an
+ * {@code app.(...)} route, so it is a different registration mechanism and this test's
+ * regex does not — and should not — see it. If {@code /mcp} ever moves to a Javalin route,
+ * add it here explicitly rather than relying on the regex to pick it up by accident.
+ */
+ private static final Set EXPECTED_ROUTES = Set.of(
+ "GET /healthz",
+ "GET /metrics",
+ "GET /sessions",
+ "GET /agents",
+ "GET /members",
+ "GET /profiles",
+ "GET /member-credentials",
+ "POST /members",
+ "DELETE /members/{paneId}",
+ "POST /sessions/{id}/message",
+ "POST /sessions/{id}/reply",
+ "GET /sessions/{id}/replies",
+ "POST /sessions/{id}/ask",
+ "GET /sessions/{id}/status",
+ "GET /tasks/{ticket}"
+ );
+
+ /**
+ * Every {@code app.("")} call in {@link FleetApp}'s source, as {@code "VERB path"}.
+ * This matches inside an {@code if (...) { ... }} block just as well as a top-level statement —
+ * the regex only looks for the call shape, not its surrounding control flow — which is what
+ * catches {@code GET /metrics} (registered conditionally on {@code metrics != null}).
+ */
+ private static Set routesTheServerRegisters() throws Exception {
+ String source = Files.readString(REST_SOURCE);
+ Matcher m = Pattern.compile("app\\.(get|post|delete|put|patch)\\(\\s*\"([^\"]+)\"").matcher(source);
+ Set found = new LinkedHashSet<>();
+ while (m.find()) {
+ found.add(m.group(1).toUpperCase(Locale.ROOT) + " " + m.group(2));
+ }
+ return found;
+ }
+
+ @Test
+ @DisplayName("[SOURCE TEXT] FleetApp registers exactly the documented REST route inventory")
+ void theRegisteredRoutesMatchTheExpectedInventory() throws Exception {
+ Set actual = routesTheServerRegisters();
+
+ Set added = new LinkedHashSet<>(actual);
+ added.removeAll(EXPECTED_ROUTES);
+ Set removed = new LinkedHashSet<>(EXPECTED_ROUTES);
+ removed.removeAll(actual);
+
+ assertTrue(added.isEmpty() && removed.isEmpty(),
+ "FleetApp's registered REST routes no longer match this test's expected inventory. "
+ + "Added (in FleetApp, not in this test): " + added + ". "
+ + "Removed (in this test, not in FleetApp): " + removed + ". "
+ + "Update EXPECTED_ROUTES in RestRouteInventoryTest AND the operator wiki's "
+ + "REST-surface entry together — this is the fleetd #252 defect: the route "
+ + "list drifted for a month with nothing checking it. Do NOT weaken this test.");
+ }
+
+ /**
+ * The denominator guard (same shape as {@code McpContractDocTest}'s vacuity check). Pins that
+ * the regex really is still finding registrations, so a scrape that silently stops matching
+ * can't make the check above pass by finding nothing on both sides.
+ */
+ @Test
+ @DisplayName("[SOURCE TEXT] the route scrape is not vacuous — it found the expected count")
+ void theScrapeActuallyFoundRoutes() throws Exception {
+ Set actual = routesTheServerRegisters();
+ assertTrue(actual.size() >= EXPECTED_ROUTES.size(),
+ "scraped only " + actual.size() + " route registration(s) from FleetApp (" + actual
+ + "), but this test expects at least " + EXPECTED_ROUTES.size()
+ + "; the app.(\"...\") scrape has stopped matching and the check above "
+ + "is now vacuous");
+ }
+}