A Blazor component cannot @inject a hub-scoped service

Symptom. Every portal in the fleet answered HTTP 200, every asset loaded, the Blazor circuit opened — and the home page (/) was white for every signed-in visitor. The only trace was one line in the browser console, written by the server as it tore the circuit down:

System.InvalidOperationException: Cannot provide a value for property 'Workspace' on type
'Memex.Portal.Shared.Pages.Index'. There is no registered service of type 'MeshWeaver.Data.IWorkspace'.

/Store, /Doc, chat and every other route rendered. Nothing was logged by the portal at a level that ships, /health stayed Degraded for its usual reasons, and the prod synthetic probe — which only ever GETs — kept reading 200.

Cause. Pages/Index.razor carried @inject IWorkspace Workspace. IWorkspace is registered by AddData() on the hub's own service collection (MessageHubConfiguration.WithServices, core DataExtensions): a hub's provider is a child lifetime scope of the host container (ServiceProviderExtensions.SetupModules(parent)BeginLifetimeScope), so anything registered there is visible from that hub and its descendants and from nothing above it. A Blazor circuit scope is a child of the host container, not of the root hub — so IWorkspace is, by construction, never resolvable through @inject / [Inject]. The same is true of every service a hub registers on itself. IMessageHub is host-registered, but MeshBuilder.AddSingleton(BuildHub) supplies the process-lifetime root mesh hub. Circuit components use the scoped PortalApplication registered by AddBlazor(), as Onboarding and BlazorView do:

@using MeshWeaver.Data
@using MeshWeaver.Hosting.AspNetCore.Portal
@inject PortalApplication PortalApplication

@code {
    private IWorkspace Workspace => PortalApplication.Hub.GetWorkspace();
}

The distinction also preserves identity: PortalApplication keys its hub by the circuit id and stamps the circuit accessor's current user on outbound posts when ambient context is absent. Deriving a workspace from the root hub removes the activation exception but does not provide that circuit identity boundary for reactive callbacks and hub work.

This is the same rule the core pages state from the other side — "IWorkspace is AddScoped, so it belongs to one hub; deliberately do NOT resolve it from a provider you did not get from that hub" (Doc/Architecture/AReferenceThatCannotBeAKey, RouterTrafficDetection) — met at the one place a Razor page can hide a resolve: the @inject directive, which reads as a declaration and is a GetRequiredService on the circuit scope at activation.

Why nothing caught it. The page's own rendering test built its provider by hand and registered mesh.GetWorkspace() as a singleton — so the test offered the page a service the real host never offers, and passed. A test fake must be scoped to what the subject can actually reach (the same lesson as Hosting/ATestFakeIsScopedToItsSubject). The end-to-end lane signs in through the Plugin Catalog page, not through /. The prod probe never opens a circuit. So the first thing that resolved Index from a real circuit was a visitor.

Why it surfaced as "everything is down" on one morning. The injection merged with the first-run-setup feature (plugins#1961), and the first portal images to carry it rolled onto both public instances the next morning, in the same hours in which the Continuous update policy rolled or restarted the public instance seven times behind a wave of merges. A white home page on every portal, a circuit that dies every half hour, and a local MCP endpoint whose token store had been wiped by an eviction all arrived together; only the first was a code defect.

The fix, and the guard. Index injects the existing scoped PortalApplication and derives the workspace from its circuit hub. The rendering test uses the real AddBlazor() registrations and a scoped circuit accessor, and asserts that the host does not offer IWorkspace. It proves both signed-in Activity rendering and the anonymous landing title, including live renames. NoComponentInjectsAHubScopedServiceTest (Memex.Portal.Gui.Test) scans every IComponent in the portal GUI and in every MeshWeaver.Blazor* assembly it references, and fails naming each [Inject] property whose type the root hub's provider can serve and the host container cannot (IServiceProviderIsService on both). The oracle is measured, not listed: a host-registered type is fine, a framework type neither side knows (NavigationManager, IJSRuntime, the portal's own scoped services registered by the web host) is outside the invariant, and the test also asserts that its oracle still reads IWorkspace as hub-scoped, so it cannot pass vacuously if the registration model changes underneath it. On the unfixed page it fails naming Memex.Portal.Shared.Pages.Index.Workspace injects IWorkspace.

If you see the symptom again. A white page with a healthy /health and a console line of the shape "Cannot provide a value for property … no registered service of type …" is this class, and the property named tells you the component. Do not register the service on the host as a forwarder into the root hub's scope: the hub a circuit should talk to is decided by the page (PortalApplication, a node's own hub), and a host-level forwarder would silently pin every component to the root hub's workspace. Use the existing owner of the correct hub and derive the service from that hub; for circuit-owned data, use PortalApplication.Hub.