The portal GUI is an optional reference
Memex.Portal.Gui is referenced by both portal hosts from an ItemGroup guarded by a condition, and
both hosts compile with it and without it. This page records why, what the GUI-less build is, and
the two rules that keep the arrangement honest.
The problem it solves
MeshWeaver.MemexTemplate — the dotnet new pack, one of the two surviving NuGet packages — is
generated by tools/generate-memex-template.cs from BOTH repositories and published to public
nuget.org. Six projects are copied verbatim, four of them out of this private repository.
Memex.Portal.Gui was the seventh and could not join them:
- it exists only in this private repository, so copying it publishes private source, and a nupkg cannot be recalled — unlisting hides a version from search, but it stays downloadable by exact version for ever;
- it publishes no NuGet package, so the generator's other escape — rewriting an unshipped
ProjectReferenceinto aPackageReference— invents an id that exists nowhere, and the template fails to restore with "package not found" naming none of this.
Both hosts referenced it unconditionally, so the generator refused to run at all without
--with-gui: "the hosts reference it unconditionally, so a template without it cannot build".
That refusal is why dotnet pack tools/MeshWeaver.MemexTemplate.Pack could not succeed and why the
package stopped at 3.0.0-rc7.
A 2026-08-26 decision to include the GUI rested on the premise "the UI is public in core today, the move is what would make it private". The move happened; the premise expired. MeshWeaver#3653 settled it the other way: make the GUI optional in the hosts. Nothing private ships.
What a GUI-less build is
Not a portal, and the generated README says so rather than promising pages the package cannot contain.
Memex.Portal.Gui carries the Blazor shell and the whole portal composition —
ConfigureMemexServices, ConfigureMemexPortal, StartMemexApplication, the first-run wizard's
catalog provider, and the App component the hosts pass to it. That composition is ~357 lines of
which ~14 touch Blazor; the rest is platform (REST, gRPC-web, SignalR, MCP, auth, static assets).
It lives there because its callers do, and it cannot move to core: it binds
MeshWeaver.Blazor.Portal, MeshWeaver.Hosting.{Sqlite,PostgreSql,SignalR,Grpc} and
MeshWeaver.InstanceSync, all of which are in this repository. So there is no arrangement in which
a template restricted to public core projects composes a full portal.
What is left is what core alone composes, and that is what each host's #else branch does:
| GUI present | GUI absent | |
|---|---|---|
| Blazor shell, pages, dev login | yes | no |
| Authentication schemes, MCP, SignalR, gRPC-web, InstanceSync | yes | no |
| SQLite / PostgreSQL storage backends, the first-run wizard | yes | no — the host stops and says which |
| Module ENDPOINTS | yes | no — see below |
The mesh, module static assets, health probes, /alive |
yes | yes |
🚨 The headless pipeline maps no module endpoints, and that is a correctness choice rather than
an omission. MapMeshModuleEndpoints puts every module's routes in a group that defaults to
RequireAuthorization(), and MeshWeaver.Mcp's additionally demands a named policy. With no
authentication schemes and no authorization middleware — both registered by
ConfigureMemexServices, which is in the GUI half — mapping them trades a missing route for a 500
on the first request. A bare AddAuthentication() does not rescue it either: an authorization
failure with no challenge scheme throws instead of answering 401. Choosing an authentication story
for a scaffolded solution is the consumer's decision.
🚨 /alive keeps its check in BOTH columns, and that took a move. ProcessProgressProbe — the
GC-saturation measurement behind the only live-tagged health check — was declared in
Memex.Portal.Gui although it reads nothing but CLR counters. Left there, a GUI-less build had an
empty live tag set, which is vacuously healthy: exactly the state in which two replicas pegged
four cores in GC and served hung pages for three and a half hours on 2026-08-25 while passing every
probe. It now lives in Memex.Portal.Distributed beside its one consumer, its test moved to
Memex.Hosts.Test (compile-linked, not referenced — a ProjectReference to a host drags its
whole closure into that bin, which the same csproj refuses for the storage modules), and the
registration is unconditional. A liveness signal is not part of an optional half.
A host started with no Graph:Storage stops immediately with the reason. It deliberately does
not carry on: without a store the ordinary startup dies four stages later in a
permission-evaluator assertion that names none of this, and it cannot serve the wizard instead
because the wizard's catalog and every backend it could offer are in the GUI half.
🚨 The #else branch is not a second copy of the composition. A parallel composition would
drift the moment the real one changed and nothing here would notice, because nothing in this
repository ever runs that branch — it exists so the public template compiles.
The two rules
1. The reference is conditional, and the constant comes from the same property. Each host
derives MemexPortalGui from Exists(...) over two candidate paths — the GUI is a sibling here
(src/Memex.Portal.Gui) and sits at the solution root in the generated template, one level further
up from the aspire/ host — then uses that one property to add both the ProjectReference and
DefineConstants MEMEX_PORTAL_GUI. Writing the condition twice is how a reference and the source
that uses it start disagreeing about whether the GUI is in the build.
-p:MemexPortalGui=false forces the absent case: a global property wins over the assignment, so
the GUI-less configuration can be built on a tree that HAS the GUI. That is the only honest way to
verify it — deleting the directory would prove something about a tree nobody has.
2. The generator holds the hosts to it. optionalProjects names the projects the template may
be generated without. Before any rewrite (after it the evidence is gone), the generator asserts
that every reference to one is guarded by a Condition on the element or an ancestor, and refuses
by name if it is not. It then drops the reference rather than rewriting it into a package —
which is exactly what MSBuild does with that condition unsatisfied.
MemexTemplateGeneratorTest covers both halves: GeneratesWithoutTheGuiProject is the shape the
pack target actually publishes (previously untested — every other case passed --with-gui), and
HostReferencesTheGuiConditionally pins the csproj arrangement where a reader can act on it,
rather than only inside a pack run.
Still open
The generated template has never been build-verified end to end, and #3653 does not change that. Two pre-existing defects survive in the copied hosts and are visible in any generated tree:
<Import Project="$(MeshWeaverRoot)/memex/MeshModulesPublish.targets" />—MeshWeaverRootis undefined in a scaffolded solution;<Compile Include="../Memex.Portal.Distributed/RequiredModulesHealthCheck.cs" />in the Monolith — correct insrc/, wrong in the template layout, where the Monolith is at the root and the Distributed host is underaspire/.
RewriteCsproj retargets ProjectReference and Sdk elements only; Import and Compile are
untouched. Fixing those is a separate change from making the GUI optional, and neither is a reason
to keep publishing private source.