Composing modules into the in-mesh test lane

Measured on MeshWeaver.Plugins at beeacfa5 (2026-09-01), against the pinned tester digest sha256:36eb4296… — the same image CI runs.

The test estate is moving onto the product's own lane: take the image, install the plugin, execute the test code. mw-plugin-test build already IS that lane — it compiles every package as a dependency cascade and executes each NodeType's Test/*.cs cases, inside the image, against the assemblies a portal loads. What stopped it was one gap, and this page is what closed it.

The gap, measured

Since MeshWeaver#2276 took the AI engine out of the platform repo, the tester image ships no module at all. Not "fewer modules" — none:

$ docker create meshweaver.azurecr.io/mw-plugin-test@sha256:36eb4296… && docker export … | grep '^app/'
27   MeshWeaver.*.dll at /app          # the core framework, and only the core framework
0    modules/                          # the directory does not exist
100  module-libs/                      # third-party shelf: Azure.*, Microsoft.Extensions.AI, Radzen.Blazor…

module-libs/ is a shelf of THIRD-PARTY libraries. No MeshWeaver.AI.dll, no MeshWeaver.Markdown.Collaboration.dll, no Blazor module. So the cascade over this repo reads:

build: 48 green, 6 red, 0 blocked of 54 in 17.2s wall
Providers      RED  Providers/ProvidersApp: compile     CS0246 'ModelProviderConfiguration'
RolePlay       RED  RolePlay/Story: compile             CS0103 'ThreadNodeType'
MyAi           RED  MyAi/Panel: compile
Collaboration  RED  Collaboration/Review: compile       CS0103 'CommentsExtensions'
Edu            RED  Edu/PromptCell: compile
Hosting        RED  Hosting/Backup … +5: compile        CS1061 IMessageHub.StartThread

🚨 Every one of those errors names the CONTENT. ModelProviderConfiguration lives in src/MeshWeaver.AI; CommentsExtensions lives in src/MeshWeaver.Markdown.Collaboration. The plugin source is correct and the reference set is short — but the compiler can only report the symbol, so the message reads as this plugin is broken. That misdirection is what MeshWeaver#2563 cost a day to, in the bake.

What fills it

The runner has accepted --module <dll> since MeshWeaver#2541 — the seam exists. Nothing populated it per package. scripts/mesh-test.py now does, from a map the packages already publish and nobody has to maintain:

So the modules composed for a run are the declared modules of the selected packages' requires closure — derived, per package, never a hand-kept list.

python3 scripts/mesh-test.py --modules <dir>        # bundles, or an image's extracted /app
python3 scripts/mesh-test.py --print-modules        # resolve and report; run nothing

Measured, same tree, same image, with the two declared modules composed:

build: 54 green, 0 red, 0 blocked of 54 in 19.1s wall; critical path (3): Store → AI → Hosting
built    54/54 package(s) green
compiled 80 node type(s)

CI runs it in the compile-check job, against the refs/ directory those steps already assemble (the image's /app plus this run's module bundles), so there is no second download to drift.

What that is worth, in cases

Compiling is the gate; executing is the point. With the modules composed — and with the three unrelated Store failures below scaffolded out so the cascade stops blocking — the whole repo runs its in-mesh suites:

build: 54 green, 0 red, 0 blocked of 54 in 18.9s wall
tested   2035 passed, 0 failed, 134 need a mesh

Of those, 630 cases belong to the six packages that could not compile at all before, and every one of them is a case that had no way to run in the product's own lane:

package cases now executing needs a mesh
Hosting 297 16
Edu 209 22
RolePlay 64 4
Collaboration 26 1
Providers 24 1
MyAi 10 1

needs a mesh is not a failure: StaticTestRunner executes the parameterless cases off the mesh and COUNTS the hosted ones rather than dropping them, so the report says exactly how much of a type's suite the mesh lane still owns.

Three things that are load-bearing and look like details

A module is referenced WHERE IT WAS FOUND, never copied out on its own. The runner uses Assembly.LoadFrom, so the module's dependency closure has to sit beside it. A lone MeshWeaver.AI.dll copied into an empty directory fails with

FATAL: bake: module '/mods/MeshWeaver.AI/MeshWeaver.AI.dll' could not be loaded —
Could not load file or assembly … The system cannot find the file specified.

— naming a file that IS there, which reads as a bad path and is not one. Bundles are therefore unpacked whole and referenced inside their own meshweaver/modules/.

The staging directory must be one the container engine shares. Docker Desktop on macOS shares $HOME and does NOT share /private/tmp: a stage under the system temp directory mounts as an EMPTY directory, every module resolves on the host, none exists in the container, and the runner blames --module for a path that is perfectly correct. The default stage is therefore under ~/.cache/meshweaver; --stage / MESH_TEST_STAGE override it.

An unresolved module is reported BEFORE the run. Left silent it arrives as a CS0246 on the plugin's own source and the reader spends their first hour on content that is not broken.

What the derivation surfaced: two undeclared dependencies

Deriving the module set from requires immediately found two packages that bind MeshWeaver.AI types while declaring only Store:

package binds declared
Providers ModelProviderConfiguration, ModelProviderNodeType, LanguageModelNodeType Store only
Hosting IMessageHub.StartThread (Hosting/Backup) Store only

Both now declare AI@^1.0.0. This is not cosmetic: a missing dependency HANGS rather than errors — a node whose module is absent cannot activate, so every read costs the caller the full 60 s SubscribeRequest timeout and the page dies with nothing saying "missing dependency". The in-mesh cascade's critical path moved from Store → Hosting to Store → AI → Hosting, which is what a correctly declared graph looks like.

What is still in the way

The lane runs --no-tests today, and that is a statement about Store, not about the facility. Three in-mesh cases fail on main:

Store is the root of the graph, so a red Store blocks its 53 dependents — with cases on, the cascade reports 0 green, 1 red, 53 blocked and says nothing about anyone else's content. Fix those three and the --no-tests comes off; that is one line.

The other half of the estate — which xunit suites can move at all, and what each one still needs — is sized separately, in the classification titled How much of the xunit mesh-test estate can move in-mesh. That page is not committed yet, so this one deliberately does not link to it: a link to a node that does not exist reads as a broken page rather than as work in flight.

A composed module must have exactly ONE producer (#1262 / Systemorph/MeshWeaver#3175)

Composition answers "can this content bind the module?". It does not answer "is there only one build of that module in this compile?" — and on 2026-09-03 the second question stopped core CD.

MeshWeaver.Markdown.Collaboration (the Essentials module) was composed with --module AND present in the portal image's /app, because MeshWeaver.Blazor.Views — which ships in the image — took a ProjectReference to it for three views. Since MeshWeaver#3022 both the bake and the gate compile against the PORTAL image's /app, so both saw two builds of one assembly name, and the bake refused to seal:

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 maintainer's ruling is the rule to hold on to: a module lives in exactly one place. Code that binds a module belongs IN that module — which is why the three collaboration views moved there rather than the guard being softened.

The failure is not symmetric, and the harsher half is the one to design against

the module is missing from… what you see
the portal's view registry the control renders through the escaped-HTML fallback slot — the page still renders
an in-mesh COMPILE reference set CS0246/CS0103 naming the content, the prebuilt assembly DECLINED ("live is ABSENT — compiling instead"), and the NodeType PARKED

MeshWeaver.Education's main was red on exactly the second shape for MeshWeaver.Maps before Plugins#1242 sealed it: "Prebuilt assembly for Cornerstone/Pricing DECLINED … 'MeshWeaver.Maps' built against ref:9AC0489F…, live is ABSENT", then CS0234, then PARKED. The error names the CONTENT, so it reads as the consuming repo breaking — the same misdirection this page opens with.

Who composes the collaboration module, per lane

Removing it from /app is safe only because every lane that compiles already composes it explicitly — none of these was added by #1262; the change removed the duplicate, not a source:

lane how it arrives
this repo's Compile every NodeType (vs core) always-modules: …,MeshWeaver.Markdown.Collaboration,… + the module-bundle-MeshWeaver.{AI,Markdown.Collaboration,Maps} download
core CD publish-bake module-artifacts: module-bundle-MeshWeaver.{AI,Markdown.Collaboration}
satellite node repos registry-modules: AI Essentials + upstream-seed: pluginsEssentials.content.module IS this assembly
portal runtime the registry-served Essentials package (preInstalled: true), plus a modules/ SEED in the image for a self-registry install

🚨 The image seed is NOT one of these. It is a portal-image publish artifact and reaches no tester or gate mesh; a lane that needs the module must compose it. And because /app no longer backstops a missing bundle, compile-check now asserts every expected bundle by name rather than only MeshWeaver.AI.dll — its comment had claimed that for a while before it was true.

The binders are easy to miss: .AddComments() lives inside a JSON string

grep --include='*.cs' does not find them. A NodeType's configuration is C# inside its index.json, and that is where most collaboration binding actually happens:

Open follow-up, filed rather than fixed here: those satellite packages declare requires: [Store] or nothing at all — never Essentials — although their content binds it. Their CI is green because registry-modules: AI Essentials composes it regardless, so the gap is invisible to every gate; it bites a runtime mesh that installs the package without Essentials. The remedy is the one Plugins#1242 applied to the five map packages: one Essentials@^1.0.0 line in each package's requires.

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