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: modulesMeshWeaver.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:

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:

#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 programnode 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:

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:

  1. 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.
  2. 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.
  3. 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."

  1. We build the image.
  2. Any further testing is done against this image, no matter where.
  3. 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:

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):

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:

🚨 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

  1. 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.
  2. Head the file with the node block — // <meshweaver>, // Id:, // DisplayName:.
  3. Static class, public static void cases, parameterless, throwing on failure.
  4. Own your assertion helper — a local four-line Expect. No xUnit, no FluentAssertions, not even MeshWeaver.Reactive.Assertions: a test compiles against the image's framework plus its own type's Source, and nothing else.
  5. Add the Tests area 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 reports tests=skipped.
  6. 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.
  7. 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:

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:

  1. move a project's tests to an in-mesh Tests area and prove they fail for the same reasons — mutation-proved, not observed green;
  2. delete the compiled project;
  3. when a fixture file has no callers left, delete it;
  4. 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:

  1. The decision step is unconditional and fails on an unreadable flag. The only if: is on actions/setup-dotnet itself, 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.
  2. "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 dotnet on 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.
  3. 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.ymlpack 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

  1. 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.
  2. The cross-repo ProjectReference web — 65 projects reaching for MeshWeaver.Fixture and the TestBase pair across $(MeshWeaverRoot). See What retires above: it dissolves with the fixture rather than being ported.
  3. No in-mesh unit-test surface for non-NodeType code. The Tests area contract is defined per NodeType. A module has no node to hang a Tests area on, so the first real piece of work is deciding what that surface is.
  4. 47 external build scripts (20 core, 27 Plugins) hold logic the mesh should own, and the Build node protocol that would hold it is armed by a flag (PreWarm__BuildProtocol) that no environment sets — the same shape as PreWarm__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

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

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.9DirDot._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.

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