diff --git a/docs/plans/2026-08-11-dashboard-ui-sweeps.md b/docs/plans/2026-08-11-dashboard-ui-sweeps.md
index 31fedbe..ed16a7c 100644
--- a/docs/plans/2026-08-11-dashboard-ui-sweeps.md
+++ b/docs/plans/2026-08-11-dashboard-ui-sweeps.md
@@ -383,7 +383,28 @@ rule is "gate the link when the page denies the role outright", not "gate everyt
refused, unauthenticated refused) in `SecretsNavGateTests`, and `/admin/secrets` joins the canonical
route list in `GatewayApplicationTests` — it is the one nav destination mounted from an RCL rather
than declared here, so a routing regression could remove it without touching this repo's pages.
-Not a rendering test: the suite has no component-testing harness, and adding one to assert a single
-`AuthorizeView` would be a large dependency for a small claim.
-**Verification.** Build 0 warnings / 0 errors; suite **895/895** (892 + 3).
+### 8a. Correction: the policy tests could not detect a deleted gate
+
+The coverage above shipped with a stated rationale — that rendering was disproportionate because the
+policy verdict "is the part that can actually be wrong". That rationale was wrong, and a review point
+from the OtOpcUa session identified why: the policy is library code this repo did not author, while
+the *wiring* is the only thing this change introduced. Worse, the check applies specifically to repos
+where the link already existed before gating — "an Administrator still sees it" is identical to the
+pre-change behaviour, so it cannot distinguish a working gate from an inert one. **Only the negative
+observation proves a gate exists at all.**
+
+`SecretsNavRenderTests` now renders `MainLayout` through the framework's static `HtmlRenderer` — no
+component-testing package needed, since the assertion is about emitted markup, not interactivity —
+and asserts the Secrets item is absent for a Viewer and for an anonymous caller, present for an
+Administrator, and that the ungated API Keys sibling stays present for a Viewer (so a later
+"consistency fix" that hides it fails loudly).
+
+**Confirmed non-vacuous by mutation**, which is the only thing that makes the absence assertions
+worth anything: with the `AuthorizeView` removed from the layout, `Rail_OmitsSecretsLink_ForViewer`
+and `Rail_OmitsSecretsLink_ForAnonymous` both go red — **and all three original policy tests stay
+green**, demonstrating the gap concretely rather than by argument. The Administrator case is retained
+as the control: without it, a rail that rendered no nav at all would satisfy both absence assertions
+and the suite would report a working gate over a blank page.
+
+**Verification.** Build 0 warnings / 0 errors; suite **899/899** (895 + 4).
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/SecretsNavRenderTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/SecretsNavRenderTests.cs
new file mode 100644
index 0000000..bcdba9c
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/SecretsNavRenderTests.cs
@@ -0,0 +1,145 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.AspNetCore.Components.Web;
+using Microsoft.AspNetCore.Components.Web.HtmlRendering;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.Auth.AspNetCore;
+using ZB.MOM.WW.MxGateway.Server.Dashboard;
+using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Layout;
+using ZB.MOM.WW.Secrets.Ui;
+
+namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;
+
+///
+/// Renders and asserts whether the side rail emits the Secrets link.
+///
+///
+///
+/// The policy-level tests in SecretsNavGateTests are not sufficient on their own, and the
+/// reason is worth stating because it is easy to miss: they would stay green if the
+/// <AuthorizeView> were deleted outright. They prove the policy decides correctly, not
+/// that the rail asks it. The wiring is the part this change actually introduced, so it is the part
+/// that needs its own evidence.
+///
+///
+/// The load-bearing assertion is the NEGATIVE one. "An Administrator sees the link" is identical to
+/// the behaviour before the gate existed, so it cannot distinguish a working gate from an inert one.
+/// Only a principal without secrets:manage failing to see the item proves a gate is there at
+/// all — which is why is the test that matters and the
+/// Administrator case is its control.
+///
+///
+/// Uses the framework's static rather than a component-testing package:
+/// no new dependency, and static rendering is enough because the assertion is about markup the
+/// server emits, not about interactivity.
+///
+///
+public sealed class SecretsNavRenderTests
+{
+ private const string SecretsHref = "/admin/secrets";
+
+ ///
+ /// The gate's proof. A Viewer holds a real, authenticated identity and still must not be offered
+ /// the link, because the page would refuse them.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task Rail_OmitsSecretsLink_ForViewer()
+ {
+ string html = await RenderRailAsync(DashboardRoles.Viewer);
+
+ Assert.DoesNotContain(SecretsHref, html, StringComparison.Ordinal);
+ }
+
+ /// Anonymous callers (the read-only localhost path) are likewise not offered it.
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task Rail_OmitsSecretsLink_ForAnonymous()
+ {
+ string html = await RenderRailAsync();
+
+ Assert.DoesNotContain(SecretsHref, html, StringComparison.Ordinal);
+ }
+
+ ///
+ /// The control for the two negatives. Without this, a rail that rendered no nav at all — a
+ /// broken layout, a throwing component swallowed somewhere — would satisfy both absence
+ /// assertions and the suite would report a working gate over a blank page. The sibling
+ /// assertions on the always-present items are what make the absence above mean "gated" rather
+ /// than "nothing rendered".
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task Rail_EmitsSecretsLink_ForAdministrator()
+ {
+ string html = await RenderRailAsync(DashboardRoles.Admin);
+
+ Assert.Contains(SecretsHref, html, StringComparison.Ordinal);
+ Assert.Contains("/apikeys", html, StringComparison.Ordinal);
+ }
+
+ ///
+ /// Pins the deliberate asymmetry: the ungated sibling stays visible to a Viewer. If someone
+ /// later "fixes the inconsistency" by wrapping the API Keys item in the same AuthorizeView,
+ /// this fails — that page renders read-only for Viewers, so hiding its link would remove
+ /// legitimate access.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task Rail_StillEmitsApiKeysLink_ForViewer()
+ {
+ string html = await RenderRailAsync(DashboardRoles.Viewer);
+
+ Assert.Contains("/apikeys", html, StringComparison.Ordinal);
+ }
+
+ private static async Task RenderRailAsync(params string[] roles)
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddAuthorization(options => options.AddSecretsAuthorization());
+ services.AddCascadingAuthenticationState();
+ services.AddSingleton(new StubAuthenticationStateProvider(roles));
+ services.AddSingleton();
+
+ await using ServiceProvider provider = services.BuildServiceProvider();
+ await using var renderer = new HtmlRenderer(
+ provider,
+ provider.GetRequiredService());
+
+ return await renderer.Dispatcher.InvokeAsync(async () =>
+ {
+ HtmlRootComponent output = await renderer.RenderComponentAsync();
+ return output.ToHtmlString();
+ });
+ }
+
+ // Supplies the authentication state the rail's AuthorizeView reads. An empty role list yields an
+ // unauthenticated principal; otherwise the identity carries an authentication type, without
+ // which every policy would fail for the wrong reason and the negative assertions would pass
+ // vacuously.
+ private sealed class StubAuthenticationStateProvider(string[] roles) : AuthenticationStateProvider
+ {
+ public override Task GetAuthenticationStateAsync()
+ {
+ ClaimsIdentity identity = roles.Length == 0
+ ? new ClaimsIdentity()
+ : new ClaimsIdentity(
+ roles.Select(role => new Claim(ZbClaimTypes.Role, role)),
+ authenticationType: "Test",
+ nameType: ZbClaimTypes.Name,
+ roleType: ZbClaimTypes.Role);
+
+ return Task.FromResult(new AuthenticationState(new ClaimsPrincipal(identity)));
+ }
+ }
+
+ // NavLink resolves hrefs against the current URI, so the rail needs a NavigationManager even
+ // under static rendering.
+ private sealed class StubNavigationManager : NavigationManager
+ {
+ public StubNavigationManager() => Initialize("https://localhost/", "https://localhost/");
+ }
+}