diff --git a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs
index 2093227e72..4cea494622 100644
--- a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs
+++ b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs
@@ -13,7 +13,7 @@ namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect;
using ServiceControl.Infrastructure.Auth;
///
-/// my/routes returns the API routes the current token may call, as { method, urlTemplate } entries.
+/// my/routes returns the API routes the current token may call, as { method, url_template } entries.
/// It is the per-instance authorization contract ServicePulse consumes: it gates UI on routes it
/// already calls rather than on the server's internal permission vocabulary.
///
diff --git a/src/ServiceControl.Hosting/Auth/MyRoutesController.cs b/src/ServiceControl.Hosting/Auth/MyRoutesController.cs
index 28a6d36ec1..9d7194bb4a 100644
--- a/src/ServiceControl.Hosting/Auth/MyRoutesController.cs
+++ b/src/ServiceControl.Hosting/Auth/MyRoutesController.cs
@@ -8,7 +8,7 @@ namespace ServiceControl.Hosting.Auth;
using ServiceControl.Infrastructure.Auth;
///
-/// Returns the API routes the current token may call, as { method, urlTemplate } entries.
+/// Returns the API routes the current token may call, as { method, url_template } entries.
/// This is the per-instance authorization contract for clients (ServicePulse): each instance reports
/// only the routes it serves, so a client matches its outgoing request against the allowed set without
/// ever learning the server's internal permission vocabulary. The endpoint is the bootstrap of that
diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs
new file mode 100644
index 0000000000..664f56b190
--- /dev/null
+++ b/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs
@@ -0,0 +1,27 @@
+#nullable enable
+namespace ServiceControl.Infrastructure.Tests.Auth;
+
+using System.Text.Json;
+using NUnit.Framework;
+using ServiceControl.Infrastructure.Auth;
+
+[TestFixture]
+class RouteManifestEntrySerializationTests
+{
+ // The my/routes manifest must have ONE wire shape across instances. The Primary instance
+ // serializes snake_case and the Monitoring instance camelCase, so RouteManifestEntry pins its
+ // field names with [JsonPropertyName]. This test guards that contract: even under a camelCase
+ // policy (as on the Monitoring host) the emitted names stay snake_case, so a client merging both
+ // instances never silently drops the differently-cased entries.
+ [Test]
+ public void Emits_snake_case_field_names_even_under_a_camelCase_policy()
+ {
+ var json = JsonSerializer.Serialize(
+ new RouteManifestEntry("GET", "/api/errors"),
+ new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
+
+ Assert.That(json, Does.Contain("\"method\""));
+ Assert.That(json, Does.Contain("\"url_template\""));
+ Assert.That(json, Does.Not.Contain("urlTemplate"));
+ }
+}
diff --git a/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs b/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs
index c697a056f2..d583803f62 100644
--- a/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs
+++ b/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs
@@ -3,6 +3,7 @@ namespace ServiceControl.Infrastructure.Auth;
using System.Collections.Generic;
using System.Linq;
+using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
///
@@ -28,8 +29,16 @@ public static string Normalize(string rawTemplate)
/// A route the server hosts, with the authorization metadata read from its endpoint.
public sealed record RouteAuthInfo(string Method, string UrlTemplate, string? RequiredPermission, bool AllowAnonymous);
-/// A single allowed-route entry returned to the client.
-public sealed record RouteManifestEntry(string Method, string UrlTemplate);
+///
+/// A single allowed-route entry returned to the client. The JSON field names are pinned with
+/// so the manifest has one stable shape regardless of each host's
+/// global JSON naming policy (the Primary instance serializes snake_case, the Monitoring instance
+/// camelCase). Without this the same contract would emit url_template on one instance and
+/// urlTemplate on another, and clients that merge both would silently drop half the routes.
+///
+public sealed record RouteManifestEntry(
+ [property: JsonPropertyName("method")] string Method,
+ [property: JsonPropertyName("url_template")] string UrlTemplate);
///
/// Projects the route table down to the entries a caller may invoke. A route is included when it is