The Platform Image's Closure

The platform image is the compiler and the reference set — that is the first line of Module Build Architecture, and it is what makes the fleet's build shape work: a module does not compile against a source tree or a feed, it compiles against the assemblies the portal will actually load.

It also means the image's application directory is a contract, published to every satellite in the fleet. Until 2026-09-05 nothing stated that contract and nothing checked it. Every consumer discovered what the image contains by failing to compile against it — hours later, in a repository whose own diff was innocent, on pull requests that had changed nothing relevant.

Two breaches arrived on the same day, from opposite directions, and neither was visible in the repository that caused it.

The two invariants

Invariant What a breach costs
1 One producer. No assembly name composed as a MODULE may also be in the host's /app or its meshweaver-surface.manifest. Every NodeType binding the name is DECLINED at adoption on every portal — dependency record mismatch — built against mvid:…, live is ref:…
2 The set resolves. No assembly in /app may bind a HIGHER version of another assembly in /app than the copy shipped beside it. Every module bundle in every satellite fails MSB3277 under the module lane's -warnaserror, with no change in their repositories

Both are properties of the pair of artefacts, never of either one alone. That is exactly why review does not catch them: each half is plausible, each half was written by someone with a good reason, and the two halves live in different files — sometimes in different repositories.

Breach 1 — a module's name in /app (#3327)

MeshWeaver.Markdown.Collaboration is the Essentials package's registry-served module. It was also in every portal image, because MeshWeaver.Blazor.Views — which is in the image — held a ProjectReference to it for the collaborative markdown view. Core's CD composes that same assembly with --module, so a bake saw two builds of one simple name and refused the whole publication:

compile: FATAL — module(s) MeshWeaver.Markdown.Collaboration are composed with --module AND
shipped by the platform host at '/app' — two builds of one assembly name in one bake.

The rule and the refusal already existed — BakeHost.ShippedByHostProblem (#3175). The gap was WHEN it runs. The bake happens after promote, so the image already carries a version tag every satellite can pin, and the red lands in their repository, on their pull requests.

The measurement that corrected the diagnosis

#3327 recorded that the pinned platform set ci.7755 "was built AFTER Plugins#1268 (12:48:45Z), so this is not stale-image lag". Re-measured against ACR, that is false in both directions, and the correction matters because it changes what needed fixing:

memex-portal-ai:3.0.0-rc9.ci.7755 created 2026-09-04T11:43:19Zbefore 12:48:45Z
its Plugins commit (from the <core>-p<plugins> tag) a9aaf84
ci.7757 f9d2a33-ped7b270 — Plugins ed7b270, PR #1327, 11:30:38Z
ci.7758 b0d4a82-p2f7a95d — Plugins 2f7a95d, the merge commit of #1268

Bisecting the extracted /app of every promoted image in the window:

ci.7756  MeshWeaver.Markdown.Collaboration.dll at /app root: yes
ci.7757  yes
ci.7758  no          ← Plugins#1268
ci.7794  no

So the repo-side fix landed, held, and the first image carrying it is ci.7758. The satellite red was ordinary stale-image lag on a pin that predated the fix — the mechanism the issue explicitly ruled out. What was genuinely missing was not another repo-side fix but the producer-side assertion, which is invariant 1 above.

Breach 2 — the set does not resolve (#3328)

Four module bundles failed against 3.0.0-rc9.ci.7779:

error MSB3277: Found conflicts between different versions of "SQLitePCLRaw.core"
  … between "SQLitePCLRaw.core, Version=2.1.11.2622" and "…, Version=3.0.2.2801"
  "2.1.11.2622" was chosen because it was primary
  References which depend on … 2.1.11.2622: platform-refs-effective/SQLitePCLRaw.core.dll
  References which depend on or have been unified to … 3.0.2.2801:
      platform-refs-effective/Microsoft.CodeAnalysis.Workspaces.dll

platform-refs-effective is docker cp <portal image>:/app/.. So both sides of the conflict are the image's own bytes.

Where each version comes from. Measured over the assembly metadata of the extracted image:

Neither half is a defect. Roslyn has always shipped that dangling reference, and for as long as no SQLitePCLRaw.core.dll was in /app it simply went unresolved and cost nothing — ci.7755's reference set is provably clean. The pair is the defect, and the pair was assembled by MeshWeaver.Plugins#1284, "First-run setup: SQLite becomes selectable", which added MeshWeaver.Hosting.Sqlite to Memex.Portal.Gui so a fresh install can open its own store.

Diffing the /app roots of the two promoted images:

ci.7755 → ci.7794, LEFT /app root:      MeshWeaver.Markdown.Collaboration.dll   ← breach 1's fix
ci.7755 → ci.7794, ENTERED /app root:   MeshWeaver.Hosting.Sqlite.dll
                                        Microsoft.Data.Sqlite.dll
                                        SQLitePCLRaw.batteries_v2.dll
                                        SQLitePCLRaw.core.dll
                                        SQLitePCLRaw.provider.e_sqlite3.dll

Both changes rode the same image, ci.7758. One breach closing and another opening, in one build, neither visible to anything.

Every promoted image from ci.7758 to ci.7794 carries the skew — checked by running the gate at ci.7758, ci.7779, ci.7789 and ci.7794, and by SQLitePCLRaw.core.dll being present at /app root at ci.7760, .7765, .7770, .7775 and .7778. ci.7756 and ci.7757 have no SQLitePCLRaw.core.dll at all, and their reference sets resolve clean.

Why the issue's proposed fix would have changed nothing

#3328 proposed pinning the managed trio at 2.1.11. That is what already resolves — the pin would not have moved a byte. And the other direction is closed too: SQLitePCLRaw 3.0 restructured the package family (bundle_e_sqlite3 3.0.2 depends on SQLitePCLRaw.config.e_sqlite3 + SourceGear.sqlite3 3.50.4.2; there is no lib.e_sqlite3), so moving the whole family forward would silently replace the patched native engine pinned for GHSA-2m69-gcr7-jv3q with an older one.

What actually works — pin only the two managed assemblies whose version the image's other consumer already binds, and leave the bundle and the native engine alone:

<PackageReference Include="SQLitePCLRaw.core"               VersionOverride="3.0.2" />
<PackageReference Include="SQLitePCLRaw.provider.e_sqlite3" VersionOverride="3.0.2" />

Verified three ways before landing (MeshWeaver.Plugins#1351):

The gate

.github/scripts/check-platform-reference-set.sh, run as a step of main-cd.yml's portal-image job over the publish output that becomes /app. It runs before promote, which is the whole point: an image that violates either invariant never receives a tag a consumer can select, so the red lands on the run that produced it instead of on six satellites that did not.

Invariant 1 compares the host's /app and surface manifest against the composed-module set — read out of main-cd.yml itself (jobs.plugins-modules.with.modules), never restated. The compose set and the set the image is forbidden to carry must be one list, or they drift, and a drifted second list is how a gate stays green while asserting the wrong names.

Invariant 2 does not model MSBuild's binding rules — it runs them. The gate writes a throwaway project whose <Reference> items are that directory and builds it with the module lane's own flags. A hand-written version comparison would be a second opinion about what ResolveAssemblyReferences does, and this entire defect class is two artefacts that were each individually plausible.

The probe's reference set is faithful to the real one by construction. The portal repository's src/Directory.PlatformRefs.targets — what -p:MeshWeaverRefs=<dir> actually drives — reads:

<Reference Include="$(MeshWeaverRefs)/*.dll"
           Exclude="$(MeshWeaverRefs)/$(AssemblyName).dll;@(ProjectReference->'$(MeshWeaverRefs)/%(Filename).dll')">

A root-level glob, which is why app/modules/ seeds are outside the reference set and why the probe globs the root only. The real build additionally excludes the module's own name and its project references — module-owned names, never a third-party assembly — so the probe's set is a superset of every module's. It can therefore miss no conflict a module would hit.

Anti-vacuity

A gate that cannot fail is not a gate, so every input is asserted and a missing one is RED, naming what to provision — never a skip, because GitHub paints a skipped step the same colour as a passed one:

Each of those was made to fail on purpose, because prose that asserts a guard is not a guard:

deliberate defect exit what it said
a directory that does not exist 1 "…does not exist. This gate reads the bytes that become /app; without them it verifies nothing, so it refuses rather than passes."
a directory holding 1 assembly 1 "…holds 1 assemblies at its root — that is not a platform reference set (the portal ships ~214)."
214 assemblies, surface manifest removed 1 "…has no meshweaver-surface.manifest beside its assemblies… EITHER this gate pointed at the wrong directory OR the manifest silently stopped being published (#1699). Both are RED."
surface manifest truncated to 5 entries 1 "…names only 5 assemblies; the platform's MeshWeaver.* compile surface is ~44. A truncated manifest would make the one-producer half compare almost nothing."
pointed at a workflow with no plugins-modules job 1 "jobs.plugins-modules.with.modules is absent or empty … refuses to fall back to a hard-coded list."

Mutation proof

The gate was run against the real extracted /app of both promoted images and against the fixed set, before the fix was written:

reference set invariant 1 invariant 2 exit
ci.7755 /app (210 assemblies) REDMeshWeaver.Markdown.Collaboration in the closure and the manifest OK (377 refs) 1
ci.7757 /app — the image before the transition RED — breach 1 still open OK 1
ci.7758 /app — the transition image OK — breach 1 closed here RED — breach 2 opened here 1
ci.7779 /app — the version #3328 names OK REDMSB3277 1
ci.7794 /app (214 assemblies) OK REDMSB3277, SQLitePCLRaw.core 2.1.11.2622 ↔ 3.0.2.2801 1
ci.7794 /app + the fix OK OK (381 refs) 0
a real local portal publish, fix applied OK OK (214 assemblies, 381 refs) 0
the same publish, fix reverted in place OK REDMSB3277 1

Each half fails on the image that actually broke and passes on the one that did not, so neither is a blanket red and neither is decorative. The consecutive pair ci.7757 / ci.7758 is the sharpest evidence of that: across one build the two verdicts swap, invariant 1 going green as Plugins#1268 lands and invariant 2 going red as Plugins#1284 does.

The arm64 half of ci.7794 was checked too — same 214 assemblies, same 381 resolved references, same conflict — so the gate checking both published architectures adds no architecture-specific surprise.

Invariant 1's matching is proven both ways as well, because a false positive there would stop delivery rather than protect it. Adding MeshWeaver.AI.Anthropic=… to a clean host's surface manifest leaves the gate green (a composed name must match a whole entry, not a prefix of one); adding MeshWeaver.AI=… to the same manifest turns it red, naming the assembly and stating that it was found in the manifest and not in the closure.

🚨 Fixing a breach is a two-repo act, and the second half is not automatic

Both invariants are breached by what the PORTAL HOST's closure contains, and that host lives in MeshWeaver.Plugins — but CD is keyed on CORE's HEAD sha. A plugins merge that changes what ships in the image is therefore invisible to delivery: the scheduled reconcile finds core's HEAD image set already complete and decides bake_only, so no image is rebuilt. main-cd.yml's workflow_dispatch rebuild input exists for exactly this ("the HOSTS changed, rebuild", MeshWeaver#2622).

So a fix landing only in the plugins repository leaves every satellite pinned to a broken image until an unrelated core commit happens to rebuild — which is also why this gate can be introduced safely: land the image-side fix first, then the gate. The core merge that brings the gate in is itself the core commit that rebuilds the portal, so the gate's first live run is against a closure that already contains the fix. Landing them in the other order stops delivery on the very run that adds the gate.

What this gate still cannot see

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.