In-Mesh Build and Test
Direction of record (maintainer, 2026-08-30), in the maintainer's own terms:
"We should use in-mesh build as well as in-mesh test run. No NuGet packages." "We should be able to form any plugin into this shape." "We want the memex to take care of the build, no external build script."
Four things, and the fourth is the one that reframes the other three: the mesh is the build system. CI triggers it; CI does not perform it.
This page records where that already holds, where it does not, and — the part worth arguing about — which of the gaps are engineering work and which are real boundaries.
What "in-mesh" already means here, measured
It is not aspirational. In MeshWeaver.Plugins today:
| count | |
|---|---|
in-mesh node sets with their own Test/ sources |
206 |
NodeTypes registering a literal .WithView("Tests", …) area |
85 |
compiled .Test projects under src/ |
44 |
PackageReference Include="MeshWeaver.*" anywhere |
0 |
So the NuGet half of the direction is already true: nothing consumes the platform as a package. Every
reference is a ProjectReference across $(MeshWeaverRoot) to a sibling checkout.
And the in-mesh half already has a working CI contract. The plugin gate compiles every NodeType from the source stored on its nodes and then runs its tests in the portal, reporting per type:
ok Claims/Claim: compile=Ok render=ok tests=ok
Tests host: Claims/Claim/GateProbe — the probe instance the gate created for this check
7/7 passed
compile is a real Roslyn pass over the node's sources; tests is the Tests area executed on a
probe instance the gate creates. Neither involves a package, and neither is a dotnet test.
This page is the CI-invocation layer. What a plugin build is — take the image, install dependencies as artifacts, build, test — is The Plugin Build Contract, and the cascade it runs as is The Build Process. This page is about how CI reaches them: one tool, three lines, and no repo-local script.
The gap is not NodeTypes — it is modules
The 206 node sets are already in the target shape. The 44 compiled test projects are testing
something different: modules — MeshWeaver.Threading, MeshWeaver.Speech, MeshWeaver.Mcp,
MeshWeaver.SelfUpdate.Aks, MeshWeaver.Observability, MeshWeaver.Markdown.Export and their
peers. A module is a compiled assembly the portal loads, not source the portal compiles.
So "form any plugin into this shape" reduces to a sharper question: can a module's source live on nodes and compile at runtime, the way a NodeType's does?
For most of them the answer is yes and the work is mechanical. For some it is no, and the reason is not effort:
- Static assets.
MeshWeaver.Blazor,MeshWeaver.Blazor.Radzen,MeshWeaver.Blazor.Views,MeshWeaver.Blazor.OpenStreetMapand the three portal hosts ship awwwroot/. A module cannot carry static assets, so their content is not expressible as compiled node source. - Native binaries. The interop sets — Cosmos, the Snowflake natives,
vcruntime140— are files, not C#. Nothing compiles them into existence. - The host itself.
Memex.Portal.Distributed/Monolith/Guiare what runs the mesh. They cannot be built by the thing they start.
That boundary is worth stating plainly because it decides the shape of the answer: the target is
not "no compiled projects", it is "no compiled project on the path between a source change and a
verdict about that source". A module that is only assets or only interop still ships as bytes; what
must not happen is a C# behaviour change that can only be tested by dotnet test against a
ProjectReference.
The tension with #2193, stated rather than resolved
#2193 is titled "modules ship as baked DLLs — no compile fallback, no source in the mesh DB". Read flatly, that is the opposite of this page.
They reconcile on authoring versus shipping, and the distinction is load-bearing:
- Authoring and verification are in-mesh. Source lives on nodes, compiles in the portal, and its tests run in the portal — that is how a change is written and how it earns a verdict.
- Shipping is baked. The bake compiles those sources once, ahead of time, and the artefact an install pulls is a DLL. No install compiles anything at first use, and no install needs the source.
#2193 is about the runtime contract; this page is about the development contract. If a future change makes the baked artefact the only representation — deleting the source from the mesh — then in-mesh test runs lose their subject and the two really do conflict. That has to be decided deliberately, not discovered.
The build itself: memex owns it, CI calls it
This is the pillar with the most existing machinery and the largest gap.
What already exists. Build is a first-class NodeType (src/MeshWeaver.Graph/Configuration/BuildNodeType.cs)
with a real coordination protocol — a root at Admin/Build, a chunk per unit at
Admin/Build/{chunkName}, a BuildState of None → Planning → Building → Ready | Failed | Cancelled,
and a claim arbiter where nobody is elected: candidates register a BuildClaimRequest under
their own holder id and the node's own hub grants the earliest pending one, stealing a claim whose
holder has gone. Correctness comes from node state, never from an in-memory gate, and the grant is
taken on a durable lock rather than a hub's mirror. PreWarm__BuildProtocol arms it.
So the mesh already knows how to coordinate a build. What it does not yet do is own one.
What the gap actually is, counted. The build is currently performed by 20 scripts under
.github/scripts/ in core and 27 under scripts/ in Plugins — resolve the version, compose the
module set, seal the bundles, compile-check the node repos, verify the image set, decide whether to
publish. Every one of those is logic about the mesh, executing outside it, in bash and python, where
the mesh's own types are invisible and the only test is a CI run.
The target. A build is a node whose state machine the portal advances: CI's job is to say "build
this commit" and read a verdict back, the way helm-release.yml was reduced to calling the operator
rather than knowing about Kubernetes. That is the same move as
the deployment program — node types drive the process; GitHub Actions call
memex.
🚨 The failure mode to avoid is a script that moves house. Porting compose-sealed-modules.sh
into a C# method that a workflow still invokes as a one-shot has changed nothing: the logic is still
outside the mesh's state, still unobservable while it runs, and still untested except by running CI.
The test of whether a step has really moved is whether its progress and its verdict are readable
on a node while it happens — which is exactly what BuildState already offers and nothing
currently uses.
The CI process, concretely
The maintainer's shape for a CI job, in four lines:
- uses: actions/checkout@v7 # 0. checkout git
- run: dotnet run --project <meshweaver>/src/MeshWeaver.Cli \ # 1. the CLI, from source
-- build plugin <path> # 2. pull them, run in them, build + test
--image <tester> --platform-image <portal>
🚨 The CLI is not installed from a feed. MeshWeaver.Cli is not published
(NuGet Package Retirement); a lane either runs it from a
platform checkout, as above, or — as every lane in this fleet actually does — invokes the
mw-plugin-test image's entrypoint directly, the memex build verb being only the trip into the
container.
memex build plugin <path> --image <tester> --platform-image <portal> is the whole contract. A
workflow says which plugin this job is about and which images to build it against; the tool pulls
them, runs the build inside them, works out what actually changed, closes over everything that
depends on it, builds that, runs its tests, and publishes. Nothing else in the job knows about
modules, closures, bundles, registries or the mesh — and a plugin repo's CI stops being a program
about MeshWeaver and becomes three lines.
The verb shape matters as much as the behaviour: build is the command group and plugin names
the subject, so the same tool has room for the other subjects CI needs without every repo growing
its own script for each.
Two IMAGES, two roles — the tester executes, the portal is the reference set
--image is the TESTER; --platform-image is the PORTAL, and both are required. The tester
image's /app is a strict SUBSET of the portal's — measured on 3.0.0-rc9.ci.7534, 88 assemblies
against 219: MeshWeaver.Maps, MeshWeaver.AI, MeshWeaver.ContentCollections.Indexing, the Blazor
and hosting halves are in the portal and nowhere else. A build that took its reference set from the
tester therefore could not see what every portal compiles against, and content binding one of those
assemblies failed with CS0234 The type or namespace name 'Maps' does not exist in the namespace 'MeshWeaver' — a CONTENT-shaped failure with an INFRASTRUCTURE cause, reported against source nobody
had changed.
The reusable node-repo lanes moved to the two-image shape first. The CLI path kept running
--entrypoint /app/mw-plugin-test straight from the tester image until
MeshWeaver.Manufacturing's first adoption went red on AppleMaps/Gallery and Cornerstone/Pricing
at the 3.0.0-rc9.ci.7574 pin — the same defect, one lane later.
Both stages now run from a composed gate host: the portal's /app, complete, with the tester CLI
laid beside it, started by the portal image's own dotnet as
dotnet /host/mw-plugin-test.dll … --app /app --shared-frameworks /usr/share/dotnet/shared. The
composition rules live in exactly one file, .github/scripts/compose-gate-host.sh — the portal's
files win any collision because the process IS the portal, the tester's surface manifest and
deps.json are never copied, and a directory missing any of those is refused by name. The lanes
fetch that file from the platform at their pinned ref; the CLI embeds it and runs it. Neither
carries a second copy of the rules, and a test pins the two to the same bytes.
Embedding rather than fetching also closes the skew the lanes have to live with. A workflow pinned by
its caller's uses: and a script fetched at platform-ref are two INDEPENDENTLY pinned artefacts,
and an old workflow driving a new script is how an empty module set was once sealed while every
signal stayed green. The CLI ships the caller and the script in ONE package, so the version that
composes and the version that decides what to do with the composition cannot disagree.
🚨 There is no fallback to the tester's /app, and that is deliberate. A missing
--platform-image is a refusal before anything is pulled, and the tester passed as the platform is
refused by name. Silently compiling against the subset is not backwards compatibility — it is the
defect, and it is invisible on any plugin that happens not to bind a portal-only assembly.
The two pins must come from ONE CD wave, and that is asserted rather than assumed: the tester's own
framework-identity verb reads the portal's /app as files and must resolve the identity the tester
resolves, naming the canonical assemblies each side lacks when it does not. The bake the run produces
is then checked against that same identity — bundles published under an identity no portal asks for
are inert, which is how a whole release wave once recompiled everything at boot while CI stayed
green.
The image is an ARGUMENT, not an ambient
memex build plugin <path> --image <tester> --platform-image <portal> — the images to build
against are passed in.
A plugin is built by taking the MeshWeaver image and installing its dependencies as built artifacts into it (the four steps). Which image that is decides the whole result, so it belongs in the invocation where a reader can see it, not in an environment variable resolved somewhere upstream.
🚨 This is not a preference; it is the thing that failed on 2026-08-30. The gate named
Compile every NodeType (vs core) took its 120 reference assemblies from MW_IMAGE_DIGEST — an
image — while advertising MW_PLATFORM_REF: main, a label that decided nothing. A contract merged
into core main twenty minutes earlier was therefore invisible to it, two NodeTypes failed
CS0246, and the natural conclusion from the job's name was "the type is missing from core" —
which was false, and would have sent someone to the wrong repository. 76 of 78 NodeTypes compiled
fine against the same set, which is exactly what "the framework in this image predates that merge"
predicts.
And the CLI pulls the image and runs it. That is the other half of making the image an argument:
the job does not docker login, does not docker pull, does not docker run with a volume mount
and a wall of flags. It names the image; the tool fetches it and executes the build inside it.
That is what collapses a plugin repo's CI to three lines. Everything the current workflows spend
their length on — logging in to the registry, resolving a digest, mounting /repo, wiring the bake
output, passing --seed, retrying a login that failed on a TCP reset — is the tool's business, in
one place, testable, rather than copied into every repo's YAML and drifting.
It also puts the registry-transient handling somewhere it can be done once and done properly. Bounded retry around a registry operation currently exists in four workflows as four separate shell loops, each added after a different incident.
With the image as an explicit argument:
- the skew is visible.
--image meshweaver.azurecr.io/mw-plugin-test:<tag>in the job is a fact a reader can check against the change under test;MW_IMAGE_DIGESTin an env block is not. - the label cannot lie. There is no second name for the framework, so no gate can be called "vs core" while consuming an image.
- pinning during an incident is an argument change, not an edit to a workflow under pressure —
the same property
MW_PLUGINS_REFwas given deliberately inmain-cd.yml.
What build means by default — both directions, then publish
By default the command closes over the dependency graph in both directions and ends by publishing:
- Everything the target depends on is built — the upstream closure, so the target is built against the actual current state of its dependencies rather than whatever a registry last happened to serve.
- Everything that depends on this repo's targets is built — the downstream closure, across repos. A change to core builds Plugins, Reinsurance, Education, SocialMedia, Crm and Manufacturing, because they are what depends on it.
- It ends by publishing a new package version.
🚨 "Package" here is a module bundle in the plugin registry, not NuGet. That is not in tension with "no NuGet packages" above: the platform is never consumed as a NuGet package, and what gets published at the end of a build is the plugin's own bundle, which is how installs already receive modules. There is no NuGet artefact anywhere in this picture — the CLI itself stopped being published on 2026-09-07 (NuGet Package Retirement), and the two packages that survive are entry points a newcomer starts from, not build inputs.
Why the downstream half is the important half
It is tempting to read step 2 as an optimisation. It is the opposite: it is the only step that closes a hole this fleet has been paying for repeatedly, and it had a live example on the very day this page was written.
Core merged an added overload — ObserveCompletion — which is source-compatible everywhere the
compiler looks. Nothing was removed, so no binary-compat gate would fire. But <see cref> is the
one place where adding a member breaks already-correct code, and three bare crefs in
MeshWeaver.Plugins became CS0419 under -warnaserror. Core's CI could not see them; Plugins'
CI builds against the published core, not core's main. Neither side could catch it, and the
break was found only when core CD — which builds the portal hosts from plugins-repo — went red
and delivery stopped for hours.
A build that closes downstream by default builds the dependents as part of the change that breaks them. That is #2689's ask, delivered as a property of the build rather than as another gate someone has to remember to add.
Build once, reuse everywhere — three rules
Maintainer, 2026-08-30: "I am speaking about minimalistic and maximally re-usable build."
- We build the image.
- Any further testing is done against this image, no matter where.
- Every package is built and tested exactly once.
This is not an expensive default that needs justifying — it is the cheap one, and the current arrangement is the expensive one. Closing over dependents does not mean rebuilding the fleet repeatedly; it means the closure is computed once and each package in it is built once, against the one image, wherever it happens to live.
The waste it removes, measured in MeshWeaver.Plugins/.github/workflows/ci.yml:
- 14 jobs, of which 5 check out
Systemorph/MeshWeaverand build the framework from source through-p:MeshWeaverRoot=…— the same core, compiled again per job, in the same run; dotnet build -c Release -warnaserror "$project" -p:MeshWeaverRoot="$root"appears repeatedly across those jobs, so a package's dependencies are rebuilt once per consumer rather than installed as artifacts.
Rule 2 is what forbids that: once the image exists, nothing rebuilds what is in it. A repo that compiles its dependencies "has not installed them — it has rebuilt somebody else's product, once per mesh, on every run, and it is testing bytes nobody will ever ship" (The Plugin Build Contract, step 2).
Rule 3 is what makes the bidirectional closure affordable: a package appearing in five consumers' closures is still built once. Without it, "build everything that depends on me" really would be the expensive thing I first assumed it was — with it, the closure is a set, and the cost is the size of the set, not the number of paths into it.
🚨 The property that keeps rule 3 honest is the affected-set self-test. affected-modules.py
carries a --self-test that proves the computation can say no. A set computer that always
answers "everything" satisfies rule 3 trivially and rebuilds the world; one that answers "nothing"
is a gate that tests nothing. Both look identical on a green day. Whatever replaces it must keep
that self-test and run it in CI.
The test shape: no dependencies, not even ours
Maintainer, 2026-08-30: "we are about to remove all nuget dependencies — for tests. All repos. Check plugins how we do it."
Checked, and the Plugins idiom is stricter than "no xUnit" — a test carries no dependencies at
all, measured across all 206 in-mesh test node sets (using Xunit: 0 occurrences):
Plain C#, throws on failure. A test case is a
public static voidmethod; the assertion surface is a local four-line helper, per file, owned by the test:private static void Expect(bool condition, string message) { if (!condition) throw new Exception("CourseCatalogTests failed: " + message); }No xUnit, no FluentAssertions — and deliberately not even
MeshWeaver.Reactive.Assertions. The test compiles against exactly what the image's framework provides plus its own type'sSource, so there is nothing to restore, nothing to version, and nothing that can drift against the framework under test.A
TestsAreais the runner. It enumerates(name, Action)cases and renders a pass/fail table as the type'sTestslayout area. Registered with a literal.WithView("Tests", …)in the configuration lambda — literal, because the gate reads that field as text, and an area registered only inside an extension method reportstests=skippedand asserts nothing.The gate is the executor. It creates a probe instance and runs the area;
tests=ok 7/7in the gate log is the verdict, and the Tests-area ratchet holds the population (a type that skips its area is new debt, red).
This resolves the open question stated further down ("deciding what the in-mesh unit-test surface
is" for module tests): the surface exists, it is this, and it has 206 production instances. What
retires with the compiled test projects is therefore not just MeshWeaver.Fixture — it is the
whole test-dependency graph: xUnit v3, FluentAssertions, the runner, the .trx machinery, and the
restore step that feeds them.
What is deliberately given up, stated so it is a decision rather than a discovery: parametrised
theories, per-test isolation of the runner process, IDE test-explorer integration, and .trx
artifacts. The gate log and the rendered pass/fail table are the record instead. For the fleet's
test population — behaviour checks against a live mesh — that trade has already been paid 206
times without a request for any of the four back.
TWO executors, and which one a case lands in
The section above says "the gate is the executor". That is one of two, and the difference decides whether a suite can be migrated at all — so it is the first thing to establish about any test.
StaticTestRunner (off-mesh) |
the gate (on-mesh) | |
|---|---|---|
| where | tools/MeshWeaver.PluginTester/StaticTestRunner.cs |
plugin gate, probe instance |
| what it runs | the emitted assembly, in a collectible ALC | the type's Tests layout area |
| needs a mesh | no | yes |
| a case it accepts | public static void Foo() — parameterless |
anything the area can invoke |
| a case it cannot run | one taking LayoutAreaHost, a hub, … |
— |
🚨 A case the off-mesh runner cannot execute is reported NeedsMesh — COUNTED and NAMED, never
silently dropped. That is the whole reason the classification is trustworthy: the report says
exactly how much of a type's suite the mesh lane still owns, so "it ran" and "it was skipped" can
never read alike.
This is the maintainer's split of 2026-08-30, quoted in the runner's own doc comment: compile and pure tests off the mesh ("grains cannot handle compile workload", "do not try to import mesh nodes"), the hosted cases through the gate that seeds from the build's output.
What that means when migrating an xUnit suite
Classify before converting, because only one of these is a translation job:
- PURE — parsers, projections, identity/fingerprint functions, formatters, path conventions,
option binding. Becomes
Test/*.csstatic methods, 1:1. Migrate these first; they are the fraction that gets real coverage onto the runner immediately. - MESH — anything standing up a mesh (
MonolithMeshTestBase,HubTestBase, Orleans fixtures), rendering a layout area, or awaiting a stream. These are not convertible to parameterless static methods. They belong to the gate lane, and forcing them into the off-mesh shape means deleting the thing they assert.
🚨 Never weaken a case to make it fit the shape. If an xUnit assertion cannot be expressed in-mesh, KEEP the original and record why. A migration that quietly drops coverage is worse than the xUnit dependency it removed — the suite still appears in the count, and no longer asserts anything.
Authoring an in-mesh test — the checklist
- Put it beside its subject:
<NodeType>/Test/*.cs, next to<NodeType>/Source/*.cs. Tests live where the functionality lives; a suite in another repo from its subject is a defect, not a layout choice. - Head the file with the node block —
// <meshweaver>,// Id:,// DisplayName:. - Static class,
public static voidcases, parameterless, throwing on failure. - Own your assertion helper — a local four-line
Expect. No xUnit, no FluentAssertions, not evenMeshWeaver.Reactive.Assertions: a test compiles against the image's framework plus its own type'sSource, and nothing else. - Add the
Testsarea in a sibling*TestsArea.cs, enumerating(name, Action)and rendering pass/fail — registered with a literal.WithView("Tests", …), because the gate reads that field as text and an area registered only inside an extension method reportstests=skipped. - Assert the real surface. Read the members; never invent an API. A case naming a method that does not exist teaches a call that is wrong — worse than no case at all.
- Pin the silent defaults. A fallback that returns a plausible value on no-match is exactly what rots unnoticed; a case that names it makes it deliberate.
Consistency across the fleet
The shape above is the same in every repo — there is no per-repo dialect, and a repo that deviates is behind rather than different. Two things follow:
Test/besideSource/is the only layout. Not a.Testproject, not another repo.- The assertion idiom is the local
Expect, everywhere. A repo whose suites useMeshWeaver.Reactive.Assertionsor FluentAssertions has not converted yet — check before copying an idiom from a neighbouring file, because the un-converted ones are still present.
Fleet state, measured 2026-09-01 — compiled xUnit projects remaining:
| repo | .Test projects |
test files |
|---|---|---|
| MeshWeaver (platform) | 38 | 1 239 |
| MeshWeaver.Plugins | 64 | 1 237 |
| MeshWeaver.SocialMedia | 1 | 16 |
| MeshWeaver.Crm | 0 | 0 |
| MeshWeaver.Education / .Reinsurance / .Manufacturing | 0 | 0 |
🚨 Crm is the reference, not the laggard. It has no compiled test project at all and 13 in-mesh
test files — the target state, already reached. When in doubt about the shape, read Crm/*/Test/
rather than inventing one.
What retires: the compiled test scaffolding
Maintainer, 2026-08-30: "we want to discontinue fixture project overall."
That is the concrete consequence of the three rules, not a separate initiative. MeshWeaver.Fixture
and the two TestBase projects exist only to let compiled xUnit projects stand a mesh up
in-process. In-mesh tests do not need one: the portal is already running, and the gate creates a
GateProbe instance to execute a NodeType's Tests area on. When the tests move, the scaffolding
has no callers left.
The footprint, measured:
.csproj referencing it |
|
|---|---|
| core | 26 |
| Plugins | 39 |
| total | 65 |
test/MeshWeaver.Fixture/ is BaseFixture, HubTestBase, ServiceSetup, HubFactAttribute,
AutoTestLoggingAttribute, FreshThread, FaultRecordBudget, and the IMeshQuery /
IStorageAdapter test extensions — all of it machinery for constructing and tearing down a mesh
around a [Fact]. Alongside it sit MeshWeaver.Hosting.Monolith.TestBase (31 of the Plugins
references) and MeshWeaver.Hosting.Orleans.TestBase — and behind all of it, the NuGet test
dependencies themselves (xUnit v3, FluentAssertions, the runner), which retire with it per the
no-test-dependencies shape above.
🚨 This is the cross-repo coupling, and it dissolves rather than being ported. Those 39 Plugins
projects reach into a core checkout through $(MeshWeaverRoot) because their tests are compiled.
An in-mesh test has no ProjectReference to anything — its source lives on a node and compiles in
the portal — so retiring the fixture removes the reason the two repos are wired together at build
time at all.
The order matters, and it is the opposite of tempting. Do not delete the fixture and then find homes for its 65 dependents. Retire it behind the tests:
- move a project's tests to an in-mesh
Testsarea and prove they fail for the same reasons — mutation-proved, not observed green; - delete the compiled project;
- when a fixture file has no callers left, delete it;
- the project disappears when its last file does.
🚨 A compiled test project deleted before its in-mesh replacement exists is coverage lost in silence — and this repo has spent the whole of 2026-08-30 on guards, assertions and classifiers that passed while checking nothing. The fixture's retirement is measured by tests that still fail when the code breaks, never by the reference count reaching zero.
And some of the 65 will not move, for the same reasons modules do not: a test that exercises
static assets, native interop, or a portal host has nothing to hang a Tests area on. Those want an
explicit exemption recorded here, not a quiet ProjectReference that survives because nobody
noticed it.
The runner's .NET SDK is a DECLARED need, not a reflex
"make installing dotnet sdk a feature flag" — maintainer, 2026-08-31
Every CI job in the fleet installed actions/setup-dotnet the same way: as a reflex, before anyone
asked whether the job compiles anything with it. That is the wrong shape the moment the platform
image can be the compiler, because "the SDK is here" then silently answers a question nobody
put — an unconverted step keeps working on a runner SDK the conversion was supposed to have
removed, and the migration cannot be measured by anything but reading every step.
So the install is declared, with the safe direction as the default:
| value | behaviour |
|---|---|
install (the DEFAULT) |
install unconditionally. A lane that says nothing — including one added tomorrow — inherits it. |
by-declaration |
install only if a consumer in that job still needs one. |
| anything else, the empty string included | RED. A flag that cannot be read must never resolve to "skip". |
It is carried by dotnet-sdk: on node-repo-module-pack.yml (the shared lane every node repo
calls) and by a DOTNET_SDK job env plus a CONSUMERS list in a caller's own jobs. A lane
converts by EMPTYING its consumer list, never by editing the decision.
Why it is not a skip-trapdoor
Three properties, structural rather than hoped-for — this repo's CI invariants #3 and #4 forbid anything weaker:
- The decision step is unconditional and fails on an unreadable flag. The only
if:is onactions/setup-dotnetitself, and skipping an installer is not skipping a check: every build, pack, inspect and test step still runs and still gates. The flag chooses a COMPILER; it never chooses whether to verify. - "No SDK" is ENFORCED, not merely declined. GitHub's ubuntu runners ship an SDK of their own,
so declining to install one is not the same as not having one. The job puts a failing
dotneton PATH (exit 97,::error::naming the flag), so a consumer nobody declared goes red naming the cause instead of quietly binding against whatever .NET the runner image carries. - The verdict and every consumer are printed to the log and the job summary on every run, including when the answer is install — so the two outcomes are never told apart only by what is missing.
What blocks conversion today — measured, not assumed
Nothing can opt out yet, and that is the honest state. The blockers, per lane:
| lane | blocked on |
|---|---|
node-repo-module-pack.yml → pack |
the module-pack tool (src/MeshWeaver.Plugin.Build) is dotnet build + dotnet run in every mode, container included; a module that ships a suite is blocked again by dotnet test. Converting the lane means moving the packer inside the platform image. |
MeshWeaver.Plugins portal-hosts |
Razor/Blazor hosts, 14 dotnet build projects and ~30 dotnet test suites |
MeshWeaver.Plugins memex-template |
its subject is a dotnet new template, asserted through dotnet test |
build-project's own sweep over all 54 non-test projects in MeshWeaver.Plugins/src compiles 10
green on the portal-image basis; Razor (15) and SDK source generators (14) are the two
largest refusal classes, and dotnet test is not a verb it has at all. The flag therefore buys no
runner time today — what it buys is that each of those facts is now declared next to the job it
constrains, and stated in that job's log on every run, instead of being an assumption nobody
wrote down.
What blocks "any plugin" today
- 44 module test projects with no in-mesh equivalent. Each needs its module's source expressible as node source, or an explicit exemption on one of the grounds above.
- The cross-repo
ProjectReferenceweb — 65 projects reaching forMeshWeaver.Fixtureand theTestBasepair across$(MeshWeaverRoot). See What retires above: it dissolves with the fixture rather than being ported. - No in-mesh unit-test surface for non-NodeType code. The
Testsarea contract is defined per NodeType. A module has no node to hang aTestsarea on, so the first real piece of work is deciding what that surface is. - 47 external build scripts (20 core, 27 Plugins) hold logic the mesh should own, and the
Buildnode protocol that would hold it is armed by a flag (PreWarm__BuildProtocol) that no environment sets — the same shape asPreWarm__GateReadiness, and it will need the same care about paired settings before anyone turns it on.
What NOT to do
🚨 Do not delete a compiled test project without an in-mesh test that fails for the same reason.
A module whose tests moved to a Tests area that never runs is worse than one with a dotnet test
project — the repo has spent enough on guards that check nothing.
🚨 Do not "achieve" no-NuGet by pinning a package. There are zero MeshWeaver.* package
references today; the direction is to keep it that way, not to replace ProjectReferences with pins.
Building a COMPILED project without an SDK — memex build project (2026-08-31)
"The platform builds dll completely without any external dotnet kit or nuget." — maintainer, 2026-08-30
The sections above are about NodeTypes, whose sources already compile in the portal. This one is
about the other half — the ~54 compiled projects under MeshWeaver.Plugins/src, which until now
needed a .NET SDK, a NuGet restore and a platform SOURCE checkout to build at all.
memex build project <csproj|dir> --image <image> builds one of them with no SDK and no
restore: the .csproj is evaluated without MSBuild, and every reference is resolved from the
image's own /app plus the shared frameworks installed in it. The verb is a thin trip into the
container; the work is mw-plugin-test build-project, which is already in the image and shares the
platform's own EmitPipeline for the emit.
Why the container is the reference set
A module does not run against the platform's SOURCE and it does not run against a feed: it is loaded
into the platform IMAGE and bound by the assemblies in there. So the honest reference set is what
that image ships, and the honest package versions are the ones its .deps.json records — what
SHIPPED, not what a source tree would resolve today. This is the same rule
MeshWeaver.Plugins/scripts/container-refs.py derives for the MSBuild path, ported to C# and read
from /app directly instead of from an extracted image.
A PackageReference the image supplies resolves to the image's assembly. One it does not supply
is an ADDITIONAL library — additional to the platform — and it is reported by name and refused,
never skipped. --extra-refs <dir> is the one way to supply one, so what a build needed beyond the
platform is always visible in the command that ran it.
Nothing is dropped in silence
The evaluator FAILS the load on any construct it cannot reproduce, naming the construct and the file
— an unknown element or item type, a Condition outside its grammar, an <Import> of a missing
file, a <Target> (which it cannot execute), an embedded-resource construct whose SDK manifest name
it cannot match exactly. --accept <construct> acknowledges one deliberately. The reason is that the alternative is worse
than no build: a silently dropped Nullable, NoWarn or DefineConstants produces a green build
that is not the build the SDK would have produced, and nothing downstream can tell.
The same rule governs the reference set: an unreadable /app, a missing or ambiguous .deps.json,
or MeshWeaver assemblies that disagree on their binding identity (MeshWeaver#143's failure, caught in
the image instead of at run time) each stop the run RED.
The diagnostic standard is the SDK's
Nullable reference analysis follows the project; DocumentationMode.Diagnose is on ALWAYS, so
doc-QUALITY defects surface (CS1574 unresolved cref, CS0419 ambiguous cref, CS1570 malformed XML) —
while the doc-COMPLETENESS family (CS1591/CS1573/CS1712) is suppressed exactly when the project did
not ask for a doc file, which is when csc itself would not raise it. The SDK's own default
NoWarn (1701;1702) is seeded before any Directory.Build.props appends to $(NoWarn). Warnings
fail the build by default; --allow-warnings is the deliberate opt-out.
Measured, 2026-08-31 — the sweep, before and after embedded resources
Over every non-test project in MeshWeaver.Plugins/src (54 of them), against the portal image
memex-portal-ai@sha256:6f38db08… (the pin MeshWeaver.Plugins/.github/workflows/ci.yml carries),
with --accept targets and no --extra-refs. Both columns come from the SAME image, so the delta is
the change and nothing else.
| count | why | ||
|---|---|---|---|
| green | 12 | incl. MeshWeaver.Import — 90 source files, 0 warnings under warnings-as-errors |
|
| Razor/Blazor (CS0115) | 15 | fixed 2026-08-31 — see below | |
| source generators (CS8795) | 15 | [GeneratedRegex] / [LoggerMessage] / [JsonSerializable] |
|
gRPC <Protobuf> |
3 | protoc codegen is a build task, not a compile | |
| additional libraries | 5 | Snowflake.Data, Microsoft.Data.Sqlite, Azure.Cosmos, … — supply with --extra-refs |
|
| portal hosts | 3 | an <Import> above the mount; Aspire's <Sdk> ELEMENT |
|
| before | after | ||
| --- | --- | --- | --- |
| green | 9 | 10 | |
<EmbeddedResource> refused |
19 | 0 | the gap is closed |
| Razor/Blazor (CS0115) | 12 | 15 | .razor is not compiled — fixed separately |
| source generators (CS8795) | 3 | 14 | they live in the SDK, not a runtime image |
| additional libraries | 5 | 8 | supply with --extra-refs |
gRPC <Protobuf> |
2 | 3 | protoc codegen is a build task |
<Import> above the mount |
2 | 2 | portal hosts |
Aspire <Sdk> ELEMENT |
1 | 1 | |
| other | 1 | 1 | pre-existing compile errors |
🚨 Read the two columns together, not the green count alone. Nineteen projects were failing on the resource refusal and NOTHING ELSE WAS KNOWN ABOUT THEM — a refusal stops the load, so it MASKS every gap behind it. Closing it moved one project to green and revealed the real blocker for the other eighteen: ten of them want SDK source generators, three want Razor, three want an additional library, one wants protoc. That is why the "before" column undercounts source generators by a factor of four. No project regressed, and no project fails on an embedded resource any more.
The lesson generalises: in a builder whose whole design is to refuse rather than guess, a failure ranking is a ranking of FIRST refusals, and removing the top one does not add its count to the green column — it redistributes it.
The container compiles Razor (2026-08-31)
Razor was the biggest single category above, and it was one missing file rather than a missing
feature: Razor compilation in the .NET SDK is a Roslyn source generator
(Microsoft.CodeAnalysis.Razor.Compiler) that turns each .razor into the partial class carrying
its generated BuildRenderTree override. A runtime image ships no SDK, so without the generator
every component compiled to a class with nothing to override — a wall of CS0115 that reads exactly
like broken source.
The image now carries it, in razor-generators/ beside the builder, and build-project finds and
runs it automatically for any project whose Sdk processes Razor items.
The dependency closure, measured
Exactly two assemblies, established by reading the compiler's own assembly references and then proving it by deleting one:
| assembly | why |
|---|---|
Microsoft.CodeAnalysis.Razor.Compiler |
carries RazorSourceGenerator, the [Generator] type |
Microsoft.AspNetCore.Razor.Utilities.Shared |
its one private dependency — with it absent the compiler loads, its types enumerate, and every call into it throws |
Everything else it references — netstandard, Microsoft.CodeAnalysis(.CSharp),
System.Collections.Immutable, System.Memory, System.Buffers — binds to the assemblies the
image already has. 🚨 Microsoft.Extensions.ObjectPool is NOT needed, though an older Razor
compiler build referenced it; the belief that it was is exactly why the closure was measured rather
than assumed.
Two traps, both of which fail at run time and nowhere else
🚨 The generator is built against the SDK's Roslyn, not the image's. SDK 10.0.400's copy
references Microsoft.CodeAnalysis 5.9.0.0 while the image carries this repo's pin, 5.6.0.
The default load context binds by name and refuses a lower version, so a plain Assembly.LoadFrom
fails — and a generator loader that treats "cannot load" as "not a generator" then produces a build
indistinguishable from one where nobody asked for Razor. The generator is therefore loaded into a
context that binds every assembly the HOST already has to the host's copy, version ignored. That is
also what keeps ISourceGenerator ONE type: a second Roslyn in that context would give the
generator a different interface than the driver expects.
🚨 The generator is ReadyToRun-compiled for the SDK's own RID. The same SDK 10.0.400 file carries
PE machine 0xFD1D on linux-x64, 0xD11D on linux-arm64 and 0xEC20 on osx-arm64 (the target
machine XOR'd with the operating system's R2R marker), and the wrong one throws
BadImageFormatException. mw-plugin-test publishes BOTH architectures from ONE x64 build host, so
copying the build machine's SDK once would have shipped an arm64 image that cannot compile a single
Blazor project. CD stages one directory per RID (razor-generators/<rid>/, the arm64 copy read out
of the dotnet/sdk image of the same SDK version with docker create + docker cp, so no emulation
is involved), and the builder picks the directory for the RID it is running on.
What it refuses, and why
- A project with Razor files and no generator. One named failure that says what the CS0115 wall would have been, not the wall.
- A generator that ran and emitted nothing. Not a compile error — a generator that did not recognise its input, which otherwise fails four steps later as CS0115.
- CSS isolation (
*.razor.css). Theb-…scope identifier comes from the SDK'sComputeCssScope/ApplyCssScopestasks, and this builder runs no MSBuild task, so the components would compile without their scope attributes.--accept razor-css-scopebuilds them anyway (the assembly is valid; only the isolated stylesheet stops applying). Reproducing the scope hash from memory is the guess this evaluator exists to avoid. .razorunder a project whoseSdkdoes not process Razor. The SDK's build ignores them too, so the outcome matches — but it is stated rather than skipped.--accept razor-not-compiled.
Measured, 2026-08-31 — 10 of the 11 Razor projects
Every Microsoft.NET.Sdk.Razor project in MeshWeaver.Plugins/src, built against
memex-portal-ai@sha256:6f38db08… with the builder mounted, --accept targets --accept razor-css-scope --accept embedded-resource:
| count | ||
|---|---|---|
| green, no extra help | 7 | Blazor (31 .cs + 42 .razor), Blazor.EntityViews, Blazor.Graph, Blazor.Analysis, Blazor.OpenStreetMap, Blazor.GoogleMaps, Blazor.AppleMaps |
green with --generators (the SDK's regex generator) |
+2 | Blazor.Views, Blazor.Portal — they were blocked by [GeneratedRegex] in MeshWeaver.Markdown.Collaboration, not by Razor |
green with --extra-refs |
+1 | Blazor.Radzen — Radzen.Blazor is an additional library |
| still red | 1 | Memex.Portal.Gui — a transitive MeshWeaver.Hosting.Grpc carries <Protobuf>; protoc is a build task, not a compile |
Razor is no longer what blocks any of them. The two remaining categories are the pre-existing
ones — SDK source generators and <Protobuf>.
What it does NOT do, and what each would take
- Source generators. They live in the .NET SDK, and a MeshWeaver image ships the runtime.
--generators <dir|dll>loads them when supplied; with none, the builder names the generator that did not run rather than leaving a wall of CS8795 that reads like broken source. <Protobuf>, MSBuild<Target>s and<Sdk>elements — each a named failure with an--acceptwhere one is meaningful.
Embedded resources — supported, under the SDK's own manifest names
<EmbeddedResource> items are embedded, each under the name the SDK's own
CreateCSharpManifestResourceName would have produced. That was the single biggest coverage gap
measured over MeshWeaver.Plugins/src — 15 projects failed on nothing but
<EmbeddedResource Include="Data\**\*.md">.
🚨 Fidelity of the NAME is the whole problem, because getting it wrong is SILENT. The assembly
compiles, ships, loads, and Assembly.GetManifestResourceStream(name) returns null at run time in
some other process — no error, no failing test, nothing for a review to catch. Core's own
MeshWeaver.Messaging.Hub.csproj already carries a comment describing exactly that outcome ("the
build succeeds, the main assembly carries ZERO manifest resources, every lookup falls through to the
key-fallback path, and the UI renders raw chat.new tokens"). So no rule below was recalled: each
was established by building a probe project with the real .NET SDK and reading the manifest-resource
table back out of the emitted PE.
The pipeline, reproduced step for step. AssignTargetPath assigns each item a %(TargetPath)
(Microsoft.Common.CurrentVersion.targets says so out loud: "AssignTargetPath generates TargetPath
metadata that is consumed by CreateManifestResourceNames target for manifest name generation"),
AssignCulture routes the culture-carrying items towards SATELLITE assemblies, and
CreateCSharpManifestResourceName turns the survivors into
$(RootNamespace).<mangled directory>.<file name>.
| rule | measured |
|---|---|
| the default name | $(RootNamespace) + the directory with separators as dots + the file name |
| the DIRECTORY is mangled | Data\with-dash\Three.md → …Data.with_dash.Three.md |
| the FILE NAME is not | Weird-File.Name.md → …Weird-File.Name.md, hyphen and dot intact |
| a leading digit is PREFIXED | 9digits → _9digits (never _digits) |
| a dot in a directory is a SEPARATOR | Dot.9Dir → Dot._9Dir; each half mangled on its own |
a segment reducing to one _ DOUBLES |
-- and _ both → __ — sibling dirs so named fail the real SDK build with CS1508 |
$(RootNamespace) defaults to the PROJECT name |
not $(AssemblyName); empty means no prefix at all |
LogicalName replaces the name outright |
attribute or child element |
TargetPath beats Link beats the item spec |
a Link renames a file that is already inside the project |
| a file OUTSIDE the project loses its directory | ..\shared\Shared.md → <ns>.Shared.md |
| resources are emitted PUBLIC | ManifestResourceAttributes.Public, every one |
a missing LITERAL include is CS1566 |
a glob matching nothing is legal |
Refused BY NAME rather than guessed, each with its own --accept token, because a plausible
wrong name is worse than no build:
| construct | why |
|---|---|
.resx / .restext |
the NAME is reproducible; the CONTENT needs resgen (GenerateResource), and this builder runs no MSBuild tasks. Embedding the XML under the .resources name compiles green and throws at run time. The SDK's default glob **/*.resx is reproduced so a stray one is a refusal rather than a silent omission |
| a CULTURE in the file name | the SDK routes it into a satellite assembly (de/…resources.dll) and OUT of the main one, and an explicit LogicalName does not rescue it — measured. This builder emits one assembly. WithCulture="false" on the item is the project-side fix and needs no acceptance |
DependentUpon |
replaces the name with the first CLASS declared in that file, fully qualified — extracted by MSBuild's own C# tokenizer, which skips struct/interface/enum, takes record, and drops generic arity |
ManifestResourceName |
makes the SDK skip its own naming task, which is also what sets %(LogicalName), so csc falls back to the bare file name — the metadata does not do what it appears to do |
| the build's OWN OUTPUT | Include="bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml" builds green under the real SDK from a clean tree (csc writes /doc: and reads /resource: in one invocation); this builder writes its doc file elsewhere, so it is named and skipped rather than failing the project |
Under invariant globalization the runtime reports no predefined culture even for de, so the culture
question cannot be answered at all — the capability is PROBED, and any dotted-basename resource is
refused rather than embedded under a guessed name.
The boundary is the point. This is not an MSBuild reimplementation and must not become one: it
evaluates the subset a library project under this organisation's Directory.Build.props actually
uses, and everything outside that subset is visible in the output as a refusal rather than
discovered later as a wrong answer.