π¨ Rule change, 2026-09-07 (maintainer) β see Module Adoption Policy. A declared
minMeshVersionno longer refuses, holds or skips a module anywhere on this page's lane β loadability is measured by the link probe, and an installation keeps its previous generation when a newer one does not load. Implemented in PR #3661 (2026-09-08); the sections below describe the mechanism as it runs now.
A module is a compiled MeshWeaver assembly a deployment turns on by LISTING it β no code change, no recompile of the platform. This page is the operator- and author-facing reference for the whole lane: how a module declares itself, how a deployment activates and configures it, how its bits reach the image, and how the in-mesh compiler and bake fingerprint treat it.
Declaring a module β MeshNodeProviderAttribute
A module carries one assembly-level attribute deriving from MeshNodeProviderAttribute
(MeshWeaver.Mesh.Contract). Its five hooks are the complete boot-time surface:
| Hook | What it contributes |
|---|---|
Nodes |
Mesh nodes (node types, seeds) β with .WithGlobalServiceRegistry for root DI services |
AddressTypes |
Address types for the type registry |
HubConfigurations |
The MESH hub's configuration |
DefaultNodeHubConfigurations |
Configuration applied to EVERY per-node hub (layout areas, type registrations) |
BuilderConfigurations |
The full-surface hook β a MeshBuilder β MeshBuilder fold, applied last |
HTTP endpoints ride a SEPARATE assembly attribute β MeshEndpointProviderAttribute
(MeshWeaver.Hosting.AspNetCore), applied by the host's app.MapMeshModuleEndpoints() at
endpoint-mapping time. The split is layering (the mesh contract never references ASP.NET) and
timing (endpoints map after the auth middleware). Every contribution maps inside an
authenticated-by-default group β a route is anonymous only where the module explicitly opts out β
and duplicate (verb, pattern) registrations refuse the app loudly at startup. Delisting the
module removes its routes wholesale: a 404, not a compiled optional-service 503.
MeshWeaver.Social is the first consumer β its LinkedIn connect/publish/page-sync routes ride
this hook, with the two OAuth callback routes opting out via AllowAnonymous (LinkedIn's
redirect must not bounce through a login challenge; the CSRF state cookie is the guard).
MeshWeaver.Hosting.Grpc is the second: the whole meshweaver.v1.Mesh service maps through the
hook, AllowAnonymous on every route because the transport authenticates each connection itself
(Bearer API token in gRPC call metadata, or the trusted loopback port). One piece cannot ride the
hook: the gRPC-web MIDDLEWARE must run between UseRouting and the endpoint maps, so the host
keeps a single compiled UseMeshWeaverGrpcWebWhenInstalled() line that self-gates on the module
being listed β the module listing stays the only switch.
Which routes ride the module, and which stay in the host
Not every route belonging to a module's feature belongs in the module. The dividing question is whose API is it:
- The module's OWN protocol surface rides the module. LinkedIn's OAuth callbacks and the
meshweaver.v1.MeshgRPC service exist only because that module exists; nobody calls them when it is delisted, and a 404 is the honest answer. They also carry their own auth story (AllowAnonymousplus a CSRF cookie; per-connection Bearer metadata), so nothing is left behind in the host. - The PORTAL's client API stays in the host, behind a 503 seam β even when the engine it calls
ships as a module.
POST /api/log-incidents(Observability) andPOST /api/speech/transcribe(Speech) are both this shape: the route is part of the portal's REST surface that clients are configured against, its access rule is the HOST's to state, and it resolves the module's service optionally, answering an actionable 503 that names the missing module rather than a 500 or a bare 404. Note the two state that rule differently β speech requires the portal's Bearer-onlyMcpAuthpolicy, while log-incidents isAllowAnonymousat the ASP.NET layer and gates on theLogWatch:IngestTokenshared secret (its caller is a cluster service, not a signed-in user), and is not mapped at all when that token is unset. What makes them the same case is not a shared policy but a shared owner: the host decides who may call, and the module only supplies the engine.
Two things go wrong when a portal-API route is pushed onto the hook. The caller loses the
diagnosis β "the module is not listed" becomes an indistinguishable 404 β and, more sharply, the
route loses the host's authorization policy. The module hook's group applies the default
policy; a route that needs a specific one (the portal's Bearer-only McpAuth, whose challenge
forwarding is what makes an unauthenticated API call answer 401 + WWW-Authenticate instead of
302 to an HTML login) would have to name that policy by string across the assembly boundary,
which throws at request time in any host that never registered it. Both failures pass CI and
surface as "the mobile app logs me out".
Module DI options bind through the options pipeline β
services.AddOptions<T>().BindConfiguration("Section") β never services.Configure(section):
there is no IConfiguration instance at install time. A module whose activation depends on
runtime facts guards itself with a resolve-time enabledWhen gate (the PostgreSQL indexing
module registers its provider enabledWhen the mesh database connection resolves) instead of
failing at boot.
Modules that also need explicit composition (test fixtures, bespoke hosts) expose ONE
Add<Name>() extension sharing the same internal configure path as the attribute β the two lanes
must never drift (OgCardExtensions is the reference shape).
Activating β the appsettings baseline βͺ persisted store installs
A deployment's active module set is the union of two lanes, computed at boot (before the DI
container builds) and fed to MeshBuilder.InstallAssemblies as one list:
The
Modules:Assembliesappsettings baseline β the DLLs the image ships with; the list is the operator's on/off switch for first-party packs, exactly as before. A baseline entry that fails to load fails loudly at startup, never silently.The persisted activation record β one file per module under
modules/activation.d/, written by the runtime landing service (ModuleLandingService) when a compiled module is installed from the Store. Each entry records the module name, its source, the install record's mesh path, its generation directory, its declared platform floor, and the framework MVID the landed assemblies were built against. The legacy aggregatemodules/activation.jsonis still READ (deployments already carry one) and a per-module file wins over it by name; nothing writes it any more.π¨ Why one file per module and not one index. Every portal replica mounts the same RWX
/data, and a republish after a release pushes 30+ modules concurrently. A single mutable index that each landing read, appended to and renamed over has two failure modes no retry fixes: concurrent landings of different modules lose each other's entries (last writer wins the whole list), and the rename contends for the file's SMB lease with every other reader and writer of that one path βAccess to the path '/data/modules/activation.json' is deniedon the write side (HTTP 409), and aFileNotFoundExceptionon the read side from opening into the replace window, which the reader then reported as a corrupt sidecar and booted the pod with no store modules at all. Sharding by module removes the shared cell: two writers of different modules share no path, so neither outcome is possible. The restart-required flag is a marker FILE (activation.d/.pending-restart) for the same reason β setting it is a create and clearing it is a delete, never a read-modify-write. And a record that cannot be read now costs exactly that one module, reported by name, instead of collapsing the whole answer to the empty list.
The union dedupes by module name (a store install of an already-baseline module contributes
nothing). Activation is restart-based: landing a module writes its assemblies into
modules/<name>/ and its activation entry, flags PendingRestart in the sidecar, and the module
loads on the NEXT restart β nothing is loaded into the running process (a genuinely dynamic
loader collides with the kernel snapshot). Boot consumes the PendingRestart flag: applying the
list IS the restart. Uninstall is the mirror: the entry is disabled (kept, for history), the
folder is deleted, and the change likewise takes effect at restart.
The skip rules (persisted entries only β the deployment must always boot):
- Declared platform floor β ADVISORY, never a skip (#3648). The entry's
minMeshVersionis compared with the running platform byModulePlatformFloor.DeclineReasonβ still the ONE notion of the declared requirement, shared with landing, serving and the pack-time lint β but since #3648 a floor the running platform does not satisfy decides nothing at boot: the sentence naming both versions is logged and carried onto the activation report and the module's status row ("declares platform β₯ X; running Y"), and the entry is loaded like any other. Whether it loads is MEASURED β the link probe below and the actual load. The comparison stays a semver floor, never MVID equality: a module is a plain assembly binding by simple name, so a landed module keeps loading across ordinary platform updates; MVID equality is bake semantics and belongs to the NodeType lane. Why the floor stopped gating: it ranks a continuous build below a release candidate, and on 2026-09-07 that held every production portal on its morning build for a day while every candidate would have loaded (Doc/Architecture/ModuleAdoptionPolicy, rule R2). - Missing DLL β the entry's
modules/<name>/<name>.dlldoes not exist (lost volume, manual deletion). Skipped loudly; re-install to heal. The check is that path SPECIFICALLY β a same-named DLL in the app closure never satisfies a store-installed entry (theResolveModulePathbase-directory fallback applies to baseline entries only, so a tampered sidecar can never silently bind the platform's own binaries).
The landing service itself gates twice more, at placement: the same floor check (declined bytes
never reach disk), and a refusal of any module whose entry DLL name collides with an app-closure
assembly β ResolveModulePath probes modules/<name>/ first, so such a module would silently
shadow the platform's own binary at the next boot.
The fallback rule (MeshWeaver#3649). A landed generation that does not load on the running
platform β refused by the link probe before loading, or faulting in
Assembly.LoadFrom β no longer leaves the module absent. Every landing records the entry it
displaces as PreviousDirectory (with PreviousVersion / PreviousFrameworkMvid); boot hands
MeshBuilder.InstallModules both generations and the loader runs the previous one when the head
one cannot load here, registering a FallbackModule β present, running, one version behind β and
saying so on stderr and, once the pipeline is up, as a Warning. The GC references the previous
generation like the head one; the mesh-set adoption records the generation that actually loaded;
the status row reads "runs v1.2.3 (gen A); v1.3.0 (gen B) landed but does not load here: β¦", the
readiness probe stays Healthy, and nothing says "restart required" (a restart falls back again).
The image-shipped copy is the last step (MeshWeaver#3735): when no landed generation loads and
the image ships the module (the Modules:Assemblies entry the store entry displaced β carried as
EffectiveModule.BaselineEntry, resolved onto ModuleInstallCandidate.ImageBaseline), the image's
copy runs, recorded as @image and worded "runs the image-shipped baseline; v1.3.0 (gen B) landed
but does not load here: β¦". Before that step a refused store generation shadowed the image copy
that loads by construction (memex.systemorph.com, 2026-09-08 β every skinned control on its
fallback HTML). Only when nothing loads is the module incompatible, as before; an uninstall clears both
pointers. This is rule R1 of the Module Adoption Policy: an
installation runs the newest generation of every module that loads, and keeps the one it has until
a newer one does.
π¨ "Keeps loading across ordinary platform updates" is a promise the PLATFORM owes (#2370)
The semver floor above is not a weaker gate than MVID equality β it is a different contract, and
the platform side of it is: a public type a module can bind must keep its full name and keep being
reachable from the assembly it was bound in. A module's IL holds neither a using nor a source
reference; it holds
TypeRef MeshWeaver.AI.MeshOperations scope: AssemblyRef MeshWeaver.AI
so moving a public type to another assembly, or renaming its namespace, breaks every module compiled earlier β at the next roll, with no warning anywhere:
System.TypeLoadException: Could not load type 'MeshWeaver.AI.MeshOperations'
from assembly 'MeshWeaver.AI, Version=3.0.0.0, Culture=neutral, PublicKeyToken=null'
That is #2370. MeshOperations moved to MeshWeaver.Mesh.Operations and the store-installed
MeshWeaver.Mcp could no longer construct McpMeshPlugin; because the MCP SDK builds its tool
target per invocation, EVERY tool call β get, search, create, render_area, the LSP and chunk
tools β failed identically. A full outage of the deployment's /mcp surface, for every external
client, from a change that was source-compatible and reviewed as a refactor.
The move is fine; losing the name is not. Leave a forwarder in the old assembly and keep the type's ORIGINAL full name in its new home β a forwarder cannot rename:
// src/MeshWeaver.AI/TypeForwards.cs
[assembly: TypeForwardedTo(typeof(MeshWeaver.AI.MeshOperations))]
The CLR then resolves the module's TypeRef through the old assembly to ONE type identity β not a
shim, which would mint a second identity and reintroduce the as/is trap-door.
π¨ No repo-local build can see this break, and two green gates specifically cannot.
landed-modules-gate compiled the plugins repo's module SOURCE against the PR, which is a different
question from whether the module ALREADY PUBLISHED still binds β and on #2370 it passed, because the
module's source carried using directives for both namespaces. (That job is gone besides: core
builds the image and runs its own tests, and plugins are built by the repo that owns them, so nothing
in core's CI compiles a line of module source today.) The semver floor cannot see a type at all.
scripts/check-type-forwards.py (wired into the Public surface (binary compatibility) job
beside #2298's check-record-signatures.py) is what refuses the next one; its allow file is a
statement that no shipped module can hold the TypeRef, not a way to make it quiet.
π¨ A move OUT OF THIS REPO reads exactly like a deletion, and the gate used to be silent on it.
Since #2276 the module assemblies are built in MeshWeaver.Plugins, so a public type moving from a
core assembly into one of them deletes files here and adds none. The gate's original scoping decision
β a type that vanishes from src/ entirely is out of scope, because "a deletion reads AS a deletion
in review" β therefore stopped holding, and it reported OK across v3.0.0-rc7 β main while that
window contained the seven types below. A departure (the type is gone from src/ while the
assembly it left is still built here) is now its own counted, named category and it fails; pass
--sibling <checkout> to have the gate say which departures are cross-repo moves and which are
deletions.
It had already happened again before the gate existed. Replaying that gate across
v3.0.0-rc7 β main found 17 unguarded moves; #2370 fixed four, and #2398 fixed six more that
#2276 made when it moved the credential-protection and MCP-back-connection contracts into
MeshWeaver.Mesh.Contract β IProviderKeyProtector, ProviderKeyProtector, IMasterKeyProvider,
ConfigMasterKeyProvider, IMcpBackConnection, McpConnectionInfo. Three of those have a proven
module consumer in the plugins repo today. So when reading a file under src/MeshWeaver.Mesh.Contract
that declares namespace MeshWeaver.AI (or MeshWeaver.AI.Connect) β and the one under
src/MeshWeaver.Mesh.Operations that does the same β that mismatch is the contract, not a
leftover. A forwarder cannot rename, so tidying the namespace to match its assembly re-breaks
every module built before the move. MovedTypeBinaryContractTest pins each name at runtime.
π¨ A forwarder is not always available, and that is a decision rather than a workaround. The
forwarder must live in the assembly being LEFT, so that assembly has to reference the type's new
home β impossible when the move runs against the existing reference direction. Two of #2276's
moves are exactly that (MeshWeaver.GitSync β MeshWeaver.AI and MeshWeaver.Hosting β MeshWeaver.AI; MeshWeaver.AI references both, so neither can reference it back). When a move has
that shape there are only two honest options β move the type back, or accept the break and do
the atomic republish: rebuild and republish every affected bundle, then roll the image, so no
deployment is ever running an old bundle against a new platform. Inventing a shim to dodge the cycle
is the one thing that must not happen: it mints a SECOND type identity and reintroduces the as/is
trap-door that reads as a silent null.
π¨ An ACTIVATED entry with no bytes β the GC race (#2303)
The "Missing DLL" skip above is the SYMPTOM; #2303 traced one concrete way an entry ends up
pointing at nothing: a race between ModuleLandingService.CollectGarbage (run once per pod start,
after ApplicationStarted β see the readiness section below) and a landing happening on a
DIFFERENT replica at the same moment.
A landing is two writes on the shared /data volume, deliberately ordered bytes-then-entry: it
Directory.Moves the new generation into place, THEN writes the sidecar entry that names it
(LandCore). Those two writes are adjacent in one synchronous call on the landing replica, but
nothing serializes them against a GC pass on ANOTHER replica β the per-module sidecar file and the
landing service's IO pool both bound a single process, not a cross-process sequence. If a GC pass
reads the sidecar in the gap between the other replica's two writes, the new generation directory
is on disk but no entry references it YET β indistinguishable from a genuinely orphaned directory β
and GC deletes it a moment before the landing's WriteEntry lands, pointing a real, enabled
activation entry at bytes that no longer exist. Nothing throws anywhere: the landing that raced GC
reports success (both of ITS writes succeeded), and the entry only reveals itself as unresolvable
the next time something reads it β ModuleActivationStatus.Unresolvable's loud startup report and
Degraded health check (#2093), or a boot that silently skips the module via the "Missing DLL" rule
above. That is the exact shape #2303 reported for MeshWeaver.Blazor.EntityViews: an ACTIVATED
entry whose landed assembly was gone, with no exception or stack frame naming why β likeliest to
fire during a rolling restart landing (or auto-updating) a module while sibling pods are cycling
through boot at the same time.
The fix cannot be a lock β replica coordination here is deliberately structural, not a gate. Instead
CollectGarbage carries a grace period (ModuleLandingService.DefaultGarbageMinAge, 5 minutes):
an unreferenced generation (or .staging-/.pending- leftover) younger than the window is left for
a LATER pass rather than reclaimed immediately. A directory that survives the window and is STILL
unreferenced is a genuine orphan and is collected exactly as before β the grace period defers
reclamation, it does not disable it. The two writes of a real landing are back-to-back with no I/O
between them, so the actual exposure the window has to cover is low-single-digit seconds even over
a slow network volume; five minutes is generous headroom on top of that.
π¨ GC vs a RUNNING process β three more holes, closed after the 2026-08-27 outage (#2509)
The grace window protects a landing IN FLIGHT; #2509 measured three ways GC still broke modules that had landed long ago, on both prods at once:
Unreadable is never unreferenced. The reference set GC deletes against comes from
ModuleActivationSidecar.Read, which β correctly, for boot (#2189) β skips a per-module entry file it cannot read and keeps the rest. For GC that per-module resilience inverts into a hazard: one transient SMB read fault makes that module's ACTIVE generation indistinguishable from an orphan, and the pass deletes the very bytes its entry references β a dangling activation entry with nothing naming why.CollectGarbageis now fail-closed: any entry-file read fault skips EVERY generation delete that pass (transient.staging-/.pending-/.trash-folders still collect β nothing references those by design), and a later boot re-reads and sweeps.Removal is atomic per directory. Deleting a generation in place could fail PARTWAY β one locked file aborts the recursion β and the skip-on-locked catch then preserved a HALF-GUTTED generation: entry DLL present, lazily-loaded dependency DLLs gone. A generation is now first renamed to a
.trash-*sibling (one atomic rename, after which resolution can no longer see it) and only then recursively deleted; a refused rename leaves the directory fully intact, an interrupted delete leaves only a.trash-*folder a later pass finishes. There is no half-deleted state either way.A running process loads from PROCESS-LOCAL storage (
ModuleGenerationPin). The sharedmodules/tree has reference-set lifetime, but a process needs its loaded generation for its own lifetime: dependency DLLs load LAZILY, and Roslyn content compiles (CompileReferences.ComposeWithModules) re-read module files by path hours after boot. An auto-update that lands a newer generation makes the one THIS pod loaded unreferenced, and a sibling pod's boot GC then reclaims it β correctly, by the sidecar's lights β so the pod's first lazy load afterwards wasFileNotFoundException: Could not load file or assembly 'OpenAI'. Boot now copies each store-landed generation into a per-process folder under the OS temp path and loads from there; the shared tree stays a transport that GC may reclaim freely. The pin is protection, not a gate: a boot that cannot copy warns loudly and falls back to the shared path.
π¨ Replicas of ONE deployment can run DIFFERENT module sets β and it used to be invisible (#3395)
The pin above is correct and it has a consequence the rest of the platform has to reckon with: a
process holds the generation it pinned at its own boot, for its whole life. Landing is
continuous (RegistryUpdateReconciler), restarts are not synchronised, and a Deployment's replicas
therefore boot on either side of a landing wave. Two pods of one Deployment, on one image, run two
module sets β indefinitely, until both restart.
Measured on memex-cloud, 2026-09-06. Three portal pods, one ReplicaSet, one image
(3.0.0-rc9.ci.7693), started 11:33:39, 11:41:04 and 12:51:21 around a landing wave at
12:18β12:27. Comparing /tmp/meshweaver-pinned-modules/*/ across them: 39 of 40 pinned module
generations differed between the two older pods and the newest one β e.g.
MeshWeaver.Payments.Stripe@8f251f57 versus β¦@458afe55, while the shared sidecar
(/data/modules/activation.d/MeshWeaver.Payments.Stripe.json) named β¦@458afe55. Only
MeshWeaver.Social@c000a138 was common to all three.
Why it matters beyond features. A NodeType compile stamps the module set it resolved onto the
NodeType node β CompiledModulesHash plus the per-assembly CompiledDependencies entries β and
every replica shares that ONE node. On the same day, Store/Order compiled at 12:53:21 on the
12:51 pod (MeshWeaver.Payments.Stripe: mvid:83042436β¦) and Store/Plugin at 13:09:01 on the
11:41 pod (mvid:344e6654β¦). Each replica then reads the other's stamp, HasUsableBuild /
CompiledDependencies.FindMismatch correctly declares the build stale for its environment, and
rebuilds β so the pair ping-pongs. When a replica's set genuinely LACKS a module the sources need,
the type does not merely rebuild, it FAILS: healthy β failed with no source change, which is
exactly the transition the readiness gate refuses. Issue #3395 recorded that shape and asked why
ONE process resolved two module sets; it does not β two processes do.
The defect that made it silent. ModuleActivationStatus β the per-process
restart-as-activation seam, and what /health's PendingModuleActivationHealthCheck reads β
compared the activation record against the loaded assembly simple names. That answers the
INSTALL case (a name absent here) and is blind to the UPDATE case (name present, generation moved),
which is the case a deployment is in almost all the time. Both stale pods answered /health β
Healthy with "no module activation pending" while running a 90-minute-old module set. A promise
that never fires for the change that actually happens is the gate-that-cannot-fail shape.
ModuleActivationStatus now compares the GENERATION as well: NotYetLoaded / Unresolvable take
a name β loaded generation directory leaf map (LoadedModuleGenerations(), read off each loaded
assembly's own directory leaf β which is why the pin copies a generation directory with its
<name>@<id> leaf), and an entry whose Directory differs from what this process loaded is
pending. The name-only overloads stay and forward an empty map, because replacing a signature is
what MissingMethodException-aborts a pod compiled against the previous platform. Two honesty
rules are preserved verbatim: an entry with no recorded generation (the legacy fixed
modules/<name>/ folder) names nothing to compare against, and a module whose loaded generation
this process cannot determine is unknown, never stale β over-reporting would print a
restart prompt no restart can clear, which is the same false promise the held-entry and
missing-bytes rules exist to prevent. A superseded pod whose ACTIVATED generation's bytes are gone
reports unresolvable (re-install), not pending (wait for a restart).
DECIDED β force convergence on a landing wave: one module set per mesh at a time. Detection does not end the divergence, and the policy call is the maintainer's: the stamp stays ONE per NodeType (never fanned out per environment) and the SETS converge instead. A landing wave no longer moves what the mesh runs β it stages bytes and, when the whole wave is done, PROPOSES one immutable sequenced set; boot loads the mesh's newest proposal, never its own read of the per-module entries. So every replica booting between two wave completions loads identical bytes, and a boot mid-wave cannot see a half-landed mix at all. The residual window (a replica that has not restarted since the last wave) is now singular, bounded and named β
ConvergencePendingis open exactly while the mesh has proposed a set no replica has booted onto. The full design, the record layout, the GC consequence and the rejected alternatives are in Module Set Convergence.
π¨ GC is OFF the readiness path (#2684)
Where the pass runs is as load-bearing as what it deletes. It used to run synchronously in the
portal's boot path β before the host listened β and on an Azure Files (CIFS) /data the
rename-then-recursive-delete of orphaned generations is one SMB round-trip per file: minutes of
uninterruptible IO for a handful of directories. Rollout time thereby became a function of how much
garbage the previous generation left on a network volume, which is unbounded and invisible until
the probe kills the pod: memex-cloud's roll to ci.6559 sat as PID 1 in Dsl at
wchan=wait_for_response, never bound :8080, blew the 300 s startup probe β whose kill cannot land
on a process parked in uninterruptible IO β and looped, wedging the whole helm upgrade. Raising
the probe budget would only move the cliff.
Reclaiming orphans is housekeeping: valid at any time, needed by nothing the portal serves. So the
pass now runs from ModuleGenerationsGcHostedService, registered by the same boot path that used
to call it: StartAsync only registers an ApplicationStarted callback (it can never delay the
listener), the callback schedules CollectGarbage on the file-system IIoPool, and the pass
observes the pool's cancellation between directories so a mesh teardown never waits out a slow
unlink. Nothing about the pass gates /health or /alive, and nothing about its SEMANTICS
changed: same rules, same grace window, same atomic .trash-* rename β and the reference set is
re-read from the per-module sidecar files at run time, so a post-start pass sees a set at least as
fresh as the boot-time pass did. The running process is immune to its own reclaim because it loads
store-landed generations from the process-local pin (above), never the shared tree β the same
property that already protected it from a SIBLING pod's pass.
Why a sidecar file and not a mesh node: the list is consumed before any storage provider, hub, or connection string exists, and it must move with the DLLs it describes β the landing service writes both in one operation onto the same volume, so they cannot drift apart.
The current first-party inventory and each module's configuration section:
| Module DLL | Concern | Configuration |
|---|---|---|
MeshWeaver.AI.OpenAI.dll |
OpenAI-compatible model providers | OpenAI, OpenAICompatible:Models |
MeshWeaver.AI.AzureFoundry.dll |
Azure Foundry + Anthropic-on-Azure providers | AzureFoundry, Anthropic |
MeshWeaver.AI.ClaudeCode.dll |
Claude Code harness | ClaudeCode |
MeshWeaver.AI.Copilot.dll |
Copilot harness | Copilot |
MeshWeaver.AI.WebSearch.dll |
Agent web-search tools (SearchWeb, FetchWebPage, feed readers) |
WebSearch (self-gates on credentials) |
MeshWeaver.Blazor.Radzen.dll |
Radzen view pack (charts etc.) | β |
MeshWeaver.Blazor.Analysis.dll |
Analysis view pack | β |
MeshWeaver.Blazor.GoogleMaps.dll |
Google Maps map provider | GoogleMaps |
MeshWeaver.ContentCollections.Indexing.PostgreSql.dll |
Content indexing (PG) | gated enabledWhen the mesh DB resolves |
MeshWeaver.Speech.dll |
Speech transcription | Speech |
MeshWeaver.Markdown.Export.dll |
Document export (PDF/DOCX/HTML/email) | β |
MeshWeaver.Observability.dll |
Red-log ticketing / log watch | LogWatch |
MeshWeaver.OgCard.dll |
Link-preview (og-card) layout area | β |
MeshWeaver.Notifications.Channels.dll |
Notification delivery channels (rule/channel node types + AI triage escalation) | Email (triage self-skips unless Email:Enabled) |
MeshWeaver.Social.dll |
LinkedIn publishing: connect/publish/page-sync endpoints + node-menu actions | Social:LinkedIn |
MeshWeaver.Teams.dll |
Microsoft Teams bot channel: messaging endpoint, inbound routing into threads, proactive replies | Teams (inert until bot credentials set) |
MeshWeaver.SelfUpdate.Aks.dll |
AKS/ACR mechanics: ACR tag reads, Kubernetes deployment patching, cluster instance provisioning (the self-update POLLER stays in the platform) | SelfUpdate, Instances |
MeshWeaver.Courses.dll |
Course delivery: the entitlement-gated /assets/{Space}/β¦ route over a Space's synced repo |
GitHub:App:* (shared with GitSync) |
MeshWeaver.Mail.MicrosoftGraph.dll |
Mail over Microsoft Graph: system email, inbound intake + its webhook, the Executive Assistant's mailbox tools | Email (Enabled, InboundEnabled) |
MeshWeaver.Import.dll |
Tabular import: Excel/CSV readers (its private MeshWeaver.DataSetReader.* closure), mapping configuration, the ImportRequest handler |
β (π¨ list it FIRST β see below) |
MeshWeaver.Mcp.dll |
The Model Context Protocol server: the mesh tool surface + the /mcp HTTP transport |
Mcp (BaseUrl; the McpAuth policy stays platform-side) |
MeshWeaver.Hosting.Grpc.dll |
The mesh gRPC transport: meshweaver.v1.Mesh + gRPC-web, py/node foreign participants AND the React GUI's browser data plane |
Grpc (TrustedPort) |
MeshWeaver.Hosting.Cosmos.dll |
Cosmos DB storage backend (keyed adapter factory + native query) | selected by Graph:Storage:Type = Cosmos |
MeshWeaver.Hosting.Snowflake.dll |
Snowflake storage backend (persistence, change feed, cross-schema query, access projection) | selected by Graph:Storage:Type = Snowflake |
MeshWeaver.AI.dll |
The AI ENGINE β the agent runtime (threads, rounds, delegation, tool calling, harnesses, token accounting) and the catalogs that administer it (Agents, Skills, Providers, Models, Tiers) | Features:StaticRepoSync:Partitions, Features:Ai:Clis:*, Skills:Directory, ClaudeConnect |
π¨ On a deployment with a plugin catalog the AI engine is registry-served, so it is listed under
Modules:Required and NOT under Modules:Assemblies β see Deciding below for why those two
lists are mutually exclusive for one name. (Modules:Assemblies remains the correct lane for the
engine on a host that has no catalog and therefore ships it in its own closure β the LocalMesh case
immediately below. The rule is about not listing it in BOTH, not about one lane being wrong.) Its Store entry is preInstalled, so a first-party deployment lands it unattended, and
Required is what turns an absence into a degraded readiness report rather than a silently
model-less portal (no chat, no models, and Provider/* empty β the catalog is engine-projected).
π¨ Modules:Required is an ARRAY, and configuration merges arrays BY INDEX β a deployment's own
list replaces the image's entries one for one and leaves the rest standing, so a shorter list still
requires the image's tail and an EMPTY list requires the image's list in full. A record says "these
and only these" with the scalar claim described in
Required Module Authority; read that before writing or emptying one.
π¨ Memex.LocalMesh is the exception that shows the rule. The headless sidecar has no plugin
catalog β no registry client, no auto-install β so a Modules:Required entry there would name a
module nothing can ever land, and every chat send would be refused "NodeType 'Thread' is not
registered". It keeps the engine in its own app closure instead; with no install path, there is
nothing for a registry module to collide with. A host without a catalog cannot consume the
registry lane at all β check that before flipping any module on a new host.
π¨ MeshWeaver.Hosting.Grpc is DEFAULT-ON in every deployment. Its endpoint is not just the
foreign-participant (py/*, node/*) transport β the React GUI connects over the very same
grpc-web Connect+Deliver split at the origin root (clients/portal-next, clients/portal).
Delist it only in a deployment with NO React GUI and NO foreign participants; anywhere else a
delist silently breaks the React frontend's live connection. (The former Features:Grpc flag is
gone β the module listing is the switch.)
π¨ MeshWeaver.Import is listed FIRST, and a module that registers nothing is still doing work.
No host ever called AddImport() β AddImport(...) is an application-level call a data source
makes for itself, and the portals referenced the assembly for exactly one reason: so that in-mesh
source could using MeshWeaver.Import. NodeType sources compile against
TRUSTED_PLATFORM_ASSEMBLIES composed with the deployment's installed modules
(CompileReferences.ComposeWithModules), and MeshBuilder.InstallAssemblies records an
InstalledModuleAssembly for every listed DLL β attribute or not β so listing it is what keeps
that compile surface. Because the reference set is composed in list order, a module whose own
content compiles against MeshWeaver.Import must be listed after it.
Note what a module contributes to that surface: its entry assembly, not its private closure. A
module's own dependencies (here the six MeshWeaver.DataSetReader.* assemblies, plus
MeshWeaver.DataStructures and CsvHelper) resolve at
RUNTIME from the module folder, but they are not metadata references β so in-mesh code may use the
module's public types freely, and would need the platform to carry any other assembly whose types
appear in those signatures. Keep a module's in-mesh-facing surface self-contained.
Boot packs select by OTHER configuration too: Graph:Storage:Type Cosmos/Snowflake requires
the matching MeshWeaver.Hosting.Cosmos/.Snowflake DLL in this list β installation runs before
storage selection, so ordering is safe. Delisting a UI module removes its areas mesh-wide;
embeds of a removed area render the standard area-not-found placeholder (documented per module).
Both storage backends ship in the image but are listed by nobody β every memex portal runs
PostgreSQL β so selecting one is purely an appsettings edit in the deployment that wants it.
They ride the closure lane rather than the Store bundle lane on purpose: persistence selection
reads Graph:Storage during boot, so a storage backend cannot be something the mesh installs
for itself once it is already running. The bits cost ~25 MB of publish output (Cosmos ~15 MB with
the Direct/ServiceInterop client, Snowflake ~10 MB β its driver carries Arrow plus the AWS and
GCS SDKs for stage transfer); -p:PublishMeshModules=false skips the whole layout for a host
that wants none of it.
Being bootstrap tier β the mesh cannot read itself without a storage backend, so the Store's
catalog lives behind the very storage an install would be delivering β is also what leaves these
two with no compiled reference anywhere in the tree, and therefore nothing that would notice their
folder going wrong. StorageModuleLayoutTest (test/Memex.Portal.Shared.Test) is that gate: it
walks the seam a portal walks and asserts nothing more β ResolveModulePath lands inside
modules/<Name>/ rather than on its app-folder fallback, the private driver survived the prune and
loads, InstallAssemblies folds the assembly's MeshNodeProviderAttribute, and the keyed
IStorageAdapterFactory that Graph:Storage:Type resolves comes from THAT DLL. No emulator, no
endpoint, ~40 ms. It closes two blind spots at once: the compiler proves the SOURCE binds but says
nothing about the publish layout, and the emulator suites green-SKIP when their backend is
unreachable, so they can pass by not running. The same test is what a released binary would have to
satisfy if these backends ever moved out of the platform repo (#1752) β point it at the pinned bytes
instead of the in-tree build and it answers the question a moved backend raises.
Entries resolve through MeshBuilder.ResolveModulePath: a rooted path passes through; a bare
DLL name probes modules/<name>/<name>.dll beside the app first (the publish layout below),
then falls back to the app folder.
The modules/ publish layout (#1644)
Both hosts import memex/MeshModulesPublish.targets: publishing lays every listed module out
under modules/<Name>/ beside the app, pruning same-identity files the app output already
carries. While a module still ALSO rides a ProjectReference (the transition state), its folder
prunes to empty and the loader falls back to the app folder β byte-for-byte the classic image.
Flipping a module's reference off (one module at a time, its entry upgraded to a closure layout
correct for that module) is what makes the folder carry real content; which modules EXIST then
becomes a publish (or Store-install) decision while which ACTIVATE stays the boot union above.
Skip the whole target with -p:PublishMeshModules=false.
-p:MeshModulesClosureSubset=<Name>;<Name> narrows the closure lane to the named modules, so a
project that is not a host can lay out a couple of them into its own bin/ β today only
Memex.Portal.Shared.Test, so StorageModuleLayoutTest loads the real layout rather than a copy
of it. π¨ A host must never pass it: -p: is global to every project in the build. A subset naming
nothing fails the lane RED instead of laying out nothing and reporting success.
The first flipped module is MeshWeaver.Markdown.Export: no host references it any more β its
targets entry runs a full closure publish pruned against the app root AND the shared-framework
targeting packs, so its folder carries the engine assembly (measured private deps beyond it:
none; the engine's package closure still rides the app via other references). Because a flipped
DLL exists nowhere else, the closure lane also lays it into a plain build's output
(bin/β¦/modules/), keeping dotnet run on a host working without a publish step.
π¨ Which COPY loaded β the boot report (#2223)
Two modules/ trees are legitimate at once: the image publishes baseline packs beside the app, and
a store install LANDS its bytes as a fresh generation under the deployment's writable, pod-shared
root (modules/<Name>@<id>/). So "the pack" is not a place β and until this report existed nothing
said which of them a running portal had actually loaded.
Measured on memex-cloud 2026-08-25: the portal ran an image built from the fix's own merge commit,
the store held two newer copies of MeshWeaver.Blazor.Views that both contained the fix, and
/proc/1/maps showed the process had mapped the image copy β which did not. Every lane was
green. The mechanism is not a bug in any single step:
- a baseline
Modules:Assembliesentry resolves throughMeshBuilder.ResolveModulePath, whose probes are landed root β image β app closure; - the landed probe looks in the fixed
modules/<Name>/, which generation landing never writes, so it misses and the image copy wins; - the sidecar entry that would have named the generation is deduped away by name, silently,
because the baseline already claimed it (
ComputeEffectiveModuleEntries).
ModuleLoadReport (src/MeshWeaver.PluginCatalog/ModuleLoadReport.cs) makes that visible. At boot,
immediately before InstallAssemblies, it emits one [ModuleLoad] line per pack β name, source
(appsettings / store), the exact path being loaded, its MVID, its last-write time, the commit
it was built from and the framework identity it was built against β and a STALE PACK
warning when the store holds a copy of the same module that is both newer and carries a
different MVID. Two copies with the same MVID are the same bytes in two places and warn nothing,
or the line would be noise.
[ModuleLoad] MeshWeaver.AI β /data/modules/MeshWeaver.AI@a7971ab5/MeshWeaver.AI.dll
(source=store, mvid=062dad08, written=2026-09-10 04:11:02Z,
built-from=1f2e3d4c5b6a798071625344556677889900aabb, framework=g7d644de95β¦)
The file is not the source (#4158)
π¨ mvid= and written= are both properties of the FILE, and reading them as an answer about the
SOURCE is a measured failure mode. On memex.meshweaver.cloud, 2026-09-10 (MeshWeaver.Plugins#1585),
the loaded MeshWeaver.AI bundle had the newest generation, the newest written= and types that
predated two merged pull requests. Both fields said "newest" β truthfully, because the file genuinely
was the newest one on the volume. The reading "the registry serves stale bytes" was written down and
acted on (three RefreshModules, two restarts) before /health falsified it: bundle_adoption: β¦ AI: FrameworkDeclined, i.e. the bundle had never been adopted, and the previously adopted build kept
serving under the same-MAJOR rule of ModuleAdoptionPolicy.
So the line carries two more statements, and both come from the producer rather than from the file:
| field | what it answers | where it comes from |
|---|---|---|
built-from= |
which commit of the producing repository these bytes were built from | module-pack --source-commit β the bundle manifest's sourceCommit β ModuleActivationEntry.SourceCommit |
framework= |
which platform build they were compiled against | the manifest's frameworkMvid β ModuleActivationEntry.FrameworkMvid (recorded since #3154, printed since #4158) |
Three rules hold the field to being evidence rather than decoration:
- Absence prints as
(unrecorded), never as a value. A bundle whose producer stated no commit records none, and the line says so in as many words. Filling it in from the version, the generation, the MVID or the path would produce exactly the defect the field exists to close β a current-looking marker over bytes that are not. (unknownmeans something else on this line: it is what the report says when it could not READ the file it is describing.) - The GENERATION is matched, not assumed. An entry names two generations β its head and the
previous one it falls back to when the head does not load here (#3649) β and they are different bytes
from different commits.
ModuleLoadReportreads thePrevious*fields when the path it was handed is the previous generation, and states nothing for a path the entry accounts for neither way. - Nothing gates on it. Landing is decided by the link probe and updating by
ModuleUpdateDecision's (version, framework identity) pair. Abuilt-fromthat started deciding something would make a bundle from an older packer stop landing over a field that is allowed to be absent.
Two hops still carry (unrecorded) by construction, and both are named here rather than left to be
rediscovered from an empty field.
The registry's RE-PACK composes its own manifest, elsewhere. A consumer landing a bundle it
downloaded from a producer's publish reads that producer's manifest and records the commit
(PluginBundleClient.LandFromBundle), and a registry's own [ModuleLoad] names it because its shelf
entry was written by ShelveModule. But when a registry re-serves a shelved module it composes a
fresh manifest, and that composer lives in the MeshWeaver.Plugins route, not in core src/ β so a
consumer pulling the recomposed bundle sees (unrecorded) until that route reads
ModuleActivationEntry.SourceCommit into the manifest it writes.
The producer hop is deliberately not wired in CI yet. module-pack accepts --source-commit, and
the reusable node-repo-module-pack.yml lane does not pass it, for two separate reasons β both of
which are the reason it is a flag and not an inference:
- A satellite's lane pin and its platform pin move independently. MeshWeaver.Plugins calls the lane
at
@mainwhile the pack TOOL is published from the resolved sealedplatform-ref, which lagsmain. An unconditional new flag would exit-2 (unrecognised argument) every satellite's pack jobs for the whole window between this commit and the sealed set that contains it. github.shais the wrong value. The lane reuses content-addressed module builds from earlier runs (module-build-ledger.py), so the run's own commit is not the commit that built those bytes. Stamping it would put a newer commit on older bytes β #1585 restated with one more field to be misled by. The lane must take the commit from the BUILD's provenance, which is where the remaining work is.
It reports the array it is HANDED, so the line and the load cannot disagree; the acceptance is
literally that the path in /proc/1/maps equals the path the line named (a break-glass read β
"which modules does THIS replica run" is one of the per-replica facts the Hosting API does not
report yet, OperatingFromThePortal):
kubectl exec -n <ns> <pod> -c memex-portal -- sh -c \
'cat /proc/1/maps | grep -o "[^ ]*Blazor.Views.dll" | sort -u'
kubectl logs -n <ns> <pod> -c memex-portal | grep '\[ModuleLoad\]'
π¨ It warns; it never refuses to start. Which copy ought to win is an open policy question, and
a pod that dies on the answer cannot be given the module that fixes it β the same deadlock as a
registry that cannot start delivering the module breaking it. The remedy the warning names is a
deployment decision: delist the pack from Modules:Assemblies so the landed generation stops being
shadowed.
β¦and the RECORD is not the resolution (#4158, ask 2)
π¨ get_diagnostics used to answer entirely in claims. mvid on that reply is
NodeTypeDefinition.LatestAssemblyMvid β the identity of the bytes a BUILD produced β and the record
beside it says in as many words that LatestAssemblyPath "is an ADDRESS, not an identity". The
statement nobody could get was the one the serializer and /schema/<Type> actually act on: which
CLR type this NodeType's content resolves to here, and which assembly that type came from. Those are
different statements whenever two builds of one assembly NAME are in the process β a module bundle on
the shelf and a runtime-compiled collectible build (#3732) β
and that is precisely the state in which a stale ADOPTED build and a stale registry SHELF read the
same from a consumer.
So the reply now carries a contentType block, read from the live IMeshContentTypeRegistry at the
moment of the call (ResolvedContentType.Of, src/MeshWeaver.Messaging.Hub/Serialization/):
| field | what it answers |
|---|---|
status |
resolved Β· unresolved (asked, nothing registered) Β· not-asked (no registry in this process) |
typeName |
the resolved type's full name |
assembly |
Assembly.Location β null, never "", for an assembly loaded from a byte array |
mvid |
Assembly.ManifestModule.ModuleVersionId, first 8 hex β minted by the compiler INTO the bytes, so it cannot be stale |
collectible |
true β a runtime compile in this process; false β a module shipped with it |
collectible + a null assembly is what separates the two builds of one name: an in-process compile
has no file and a collectible context, a shipped module has both the other way round. Nothing gates on
any of it β it is a reading, exactly like built-from=.
Three rules, and each is held by a case that fails without it:
- Absence is PRINTED, never inferred.
unresolvedandnot-askedare two different sentences: a missing registry MEASURED NOTHING, and reporting that as "unresolved" would report a finding nobody made. Theunresolvedsentence also names the consequence β its views render empty β because that is what an operator is actually looking at. - Every key is written, always. The block is composed as an explicit
JsonObject, not serialised from an anonymous type. Measured on the first attempt: the hub's options stamp a polymorphic$typeon a NESTED object (putting<>f__AnonymousType156[β¦]into an envelope people read) and their default ignore condition DROPS null and default members β sotypeName,assembly,mvidand afalsecollectible` all vanished, leaving a reader unable to tell not reported from reported as absent, which is the one distinction the block exists to make. - It is read from the TYPE, never from the path. The control that holds this loads the test
assembly's own bytes into a collectible
AssemblyLoadContext, so the same full name is present twice; the two readings must differ. If the answer were a function of the NodeType path β or of the node's record β they would be identical and that case goes red.
Native assets β runtimes/<rid>/native/ (#1728)
A module is loaded with Assembly.LoadFrom, which never consults the module's own deps.json, so
the runtime's fallback probe is the module's FLAT folder and nothing else. That is why the closure
lane's first prune used to delete runtimes/ outright β and why a module could not ship a native
library at all.
It can now. The publish keeps runtimes/<rid>/native/** (dropping the managed runtimes/<rid>/lib
trees, which genuinely need the deps.json, and .a/.lib link-time artifacts, which nothing can
open), and the host resolves them at load time: ModuleNativeAssets subscribes
AssemblyLoadContext.Default.ResolvingUnmanagedDll, derives the module folder from the REQUESTING
assembly's own location β so a dependency such as SkiaSharp.dll, which declares the P/Invokes
rather than the module assembly, resolves too β and probes
modules/<Name>/runtimes/<current-rid>/native/, then the flat folder.
Resolution rather than placement, because every module MSBuild invocation strips RID globals by
design (#1675/#1676): a module publish is always portable, so the RID is unknown when the bits are
laid out and only the host knows its own. The RID probe is the running RID plus its portable form
(osx.14-arm64 β osx-arm64); it deliberately does NOT walk a wider graph, because
linux-musl-x64 and linux-x64 are different C libraries and loading one for the other crashes
instead of failing cleanly.
Two modules already needed this: Snowflake P/Invokes libsf_mini_core.* (and Mono.Unix), and
Cosmos' query-plan ServiceInterop is native. Both were shipping with those files pruned away.
The registry bundle: derived and CARRIED (#4126, stage 1)
The in-image lane above has kept runtimes/<rid>/native/** since #1728. The REGISTRY bundle
(.module.nupkg) could not carry one at all: the derivation dropped it with a warning, and the
format had nowhere to put it. The first half of that is closed.
Derived. DepsClosure now reads runtimeTargets and returns the loadable natives as data
(Result.Natives, an init property β a fifth constructor parameter would be a binary break).
π¨ The old warning had a hole that made the measured case completely silent: the loop short-circuited
on RuntimeFiles.Count == 0, so a package whose ONLY contribution is native β SQLitePCLRaw.lib.e_sqlite3,
the one this was measured on β was skipped BEFORE the warning could fire. It warned about nothing and
dropped everything.
Carried. The bundle gains a third, manifest-declared section, meshweaver/modulenatives/, with
the module-relative path PRESERVED. It could not be either of the other two: every consumer of
meshweaver/modules/ filters to entries with no / in the remainder
(ServedModuleBytes, PublishedBundleCatalogue), so a native written there is silently skipped
rather than laid out; and meshweaver/moduleassets/ means static WEB assets, where conflating a
loadable binary with a served file would make every future rule about one apply to the other.
Three things are still not carried BY DERIVATION. The two that something loads now refuse the pack unless something the caller named carries them (#4367 β see Warning β REFUSAL below):
| declaration | what happens | why |
|---|---|---|
assetType: "native" at runtimes/<rid>/native/<file> |
carried | the exact layout ModuleNativeAssets probes |
assetType: "native" at any other shape |
refused unless --with-native runtimes/<rid>/native/<file> or --with <file> carries it |
bytes at a path nothing looks at read as shipped and behave as absent |
assetType: "runtime" (a RID-specific MANAGED assembly) |
refused unless --with <file> names the copy that takes the slot |
the flat closure has one slot per assembly name and no way to choose a RID at pack time |
.a / .lib |
excluded silently | link-time inputs, never loaded β the same exclusion the in-image lane applies |
π¨ "The exact layout" means EXACTLY FOUR SEGMENTS β runtimes / <rid> / native / <file> β
and that predicate has ONE spelling, NuGetPackageWriter.IsModuleNativeLayout, shared by the
derivation, the packer and the reader so the three cannot drift. A /native/ SUBSTRING test is not
the same rule and fails three ways: runtimes/<rid>/other/native/x.so and
runtimes/<rid>/native/sub/x.so would be carried and never probed, and runtimes/../../native/x.so
would make the PACKER read outside the module directory when it resolves the path against it. The
READER enforces it as well, not only the packer: that is the boundary for a producer-controlled
bundle, and the landing stage writes these paths to disk for the process to LOAD.
π¨ Native paths compare ORDINAL, unlike every managed assembly name in the same derivation.
Assembly binding is case-insensitive; a filesystem on Linux is not, so libFoo.so and libfoo.so
are two distinct loadable libraries and a case-insensitive de-duplication would silently drop one β
the exact failure this section exists to end.
And the guidance in each finding names a step the reader can actually take: --with accepts a
plain file name inside the module folder and REFUSES a path component, so a runtimes/<rid>/β¦ value
has to be flattened into the module folder root first β the loader's LAST probe is that flat folder.
Saying only "name it with --with" would send the reader to an error. Since #4367 that guidance is
also the exact value that LIFTS the refusal, so it names the value (--with RidPicky.dll,
--with-native runtimes/linux-x64/native/libodd.so), never a placeholder.
Warning β REFUSAL, armed on a measurement (#4367, the last stage of #4126)
What is refused. module-pack --deps-closure exits 2, writes nothing, and prints one error:
line per finding β naming the package, the declared path and the value that carries it β for each
asset the module's own closure DECLARES and the derivation cannot carry: a native at a layout the
loader does not probe, or a RID-specific MANAGED assembly. Both used to print warning: and pack,
leaving the module on the "the host happens to supply it" fallback.
What satisfies it β the rule, and why it is this rule. π¨ A refusal keyed on the derivation's
findings alone would be UNSATISFIABLE: deps.json keeps declaring the asset after the author follows
the advice, so the fix would be refused as well and a legitimate module blocked forever. So a finding
is refused only when NOTHING the caller named carries it (ModulePackCommand.CarrierOf):
| finding | carried by |
|---|---|
RID-specific managed runtimes/<rid>/lib/<tfm>/<file> |
--with <file>, in the bundle as written. Named, not merely present: the RID-agnostic copy that rides by derivation is exactly the silent "which RID's copy" choice the refusal stops, so it does not count β naming it is how an author says it is the right one |
native at an unprobed layout, for <rid> |
--with <file> (flattened β the loader's last probe), or a payload at exactly runtimes/<rid>/native/<file> β from --with-native or from the derivation itself, since that is the slot the loader probes for that RID and that name whoever filled it |
In the shared lane, which composes the pack arguments itself, a module cannot pass --with. It
states the carrier in its OWN csproj, and node-repo-module-pack.yml's sdk path reads it with
MSBuild's own evaluation (dotnet msbuild <csproj> -getItem:MeshWeaverPackWith -getItem:MeshWeaverPackWithNative, JSON on SDK β₯ 8; the lane runs 10.0.x) under the build's
properties, appending one flag per item:
| csproj item | pack flag |
|---|---|
<MeshWeaverPackWith Include="<file name>" /> |
--with <file name> |
<MeshWeaverPackWithNative Include="runtimes/<rid>/native/<file>" /> |
--with-native runtimes/<rid>/native/<file> |
The refusal prints the lines to paste, per finding β a Copy after Publish plus the item. A
PORTABLE publish (what the lane packs) lays every runtimeTargets asset out at its declared key, so
the copy needs no package-cache path:
<Target Name="MeshWeaverPackCarry_System_IO_Ports_unix_System_IO_Ports_dll" AfterTargets="Publish"><Copy SourceFiles="$(PublishDir)runtimes/unix/lib/net9.0/System.IO.Ports.dll" DestinationFolder="$(PublishDir)" /></Target><ItemGroup><MeshWeaverPackWith Include="System.IO.Ports.dll" /></ItemGroup>
A failed evaluation, an answer that is not the -getItem JSON shape, a value of the wrong shape and a
file the publish does not hold are all RED β never read as "declares none".
.github/scripts/test-module-pack-with.py EXECUTES the lane's block (extracted between its markers,
dotnet stubbed to the measured JSON shape, the real jq), with a falsification arm that deletes the
append. Measured end to end on the real SDK (10.0.400), 2026-09-15: a module referencing
System.IO.Ports 9.0.9 (a real RID-specific managed asset, runtimes/unix|win/lib/net9.0/) and a
local package declaring runtimes/linux-x64/nativeassets/net10.0/libodd.so was REFUSED with three
findings; the csproj lines pasted verbatim from two of them, a republish, the lane's own block run
with real dotnet msbuild -getItem, and the pack exited 0 β the bundle's System.IO.Ports.dll
byte-identical to the unix copy, libodd.so at runtimes/linux-x64/native/.
The native comparisons are ORDINAL and the managed one ignores case β the split this whole section
draws. Naming something that is NOT the declared asset does not lift it: the right file in another
RID's slot is another RID's library, and libOdd.so is not libodd.so. A native whose RID cannot
form the probed layout (none stated, or a traversal segment) is offered --with alone β the finding
never invents a slot no host probes. DepsClosure.Result.Uncarried carries the findings as data so
the packer can ask that question; Result.Warnings is the same findings in words.
Pinned in ModulePackCommandTest against the command's own entry point, per shape: a refusal
(exit 2, nothing written, the message naming package, path and carrier) and the SAME deps.json with
each prescribed remedy applied, packing β plus a control that a carrier which is not the declared
asset does not lift it, and a test that reads the shared lane's csproj lines off the refusal
verbatim, applies what they do, and packs. Negative controls, each run by rebuilding the test
project: with the arming reverted the refusal tests go red; with a naive refusal (nothing ever counts
as carrying) the remedy tests go red; with the csproj lines stripped from the refusal the shared-lane
test goes red; with the lane's read deleted (or only its append) test-module-pack-with.py goes red;
with the container lane's naming reverted its four NativeClosureTest cases go red.
The measurement it was armed on β the #3240 shape, a measurement and not a judgement: a full
node-repo-module-pack wave from both repos that publish modules printed zero of either finding.
| repo | run | bundles | on the --deps-closure path β the refusal's whole population |
occurrences |
|---|---|---|---|---|
MeshWeaver.Plugins |
34939850754 (2026-09-15, main) |
41 (the whole module set; the scheduled full wave packs the same 41) | 3 β the other 38 are container-built | 0 |
MeshWeaver.SocialMedia |
34941532528 (2026-09-15, main) |
1 | 1 | 0 |
So the refusal had no live victim when armed. π¨ State the denominator with the zero: 38 of 41
Plugins bundles print container path: 0 native payload(s) declared, and the container lane never
runs this derivation at all β the zero speaks for the four --deps-closure packs, which are exactly
the packs the refusal can fire on.
LANDED, RE-SERVED, and derived by BOTH lanes (#4126, stages 2β4)
A carried native is now written to disk where the loader probes it, on every route a module travels, and the container derivation derives one of its own.
Landing. ModuleLandingService.LandModule / ShelveModule take a nativeAssets argument and
write it under modules/<generation>/, keeping the relative path; the publish endpoint reads it off
the upload (BundleReader.ReadModuleNativeAssets) and a consumer's PluginBundleClient reads it off
the bundle it downloaded. Three things make that safe rather than merely wired:
- The layout is validated at every door, by ONE predicate.
NuGetPackageWriter.IsModuleNativeLayoutis checked by the derivation, the packer, the bundle reader,ModulePublish.Validate(a named 400, so a publisher is told rather than the server blamed) andModuleLandingServiceitself, before a byte touches disk. A payload at any other shape is REFUSED, never landed: it would be bytes that read as shipped and behave as absent. GenerationIdOffolds the natives in. The generation leaf is the content address (#3656), so two bundles differing ONLY in an engine must address two directories β otherwise the second landing adopts the first's directory and serves the wrong engine under the right version.- The landing prints a line of its own. Everything else about a native is invisible until a P/Invoke throws, so "this landing wrote N payloads, for these RIDs" is the only place a reader can tell a module that ships none from a lane that dropped them.
Registry re-serve. ModuleBundleSource.NativeAssetsOf reads the shelf's runtimes/ tree (an
ADDITIVE call, not a fourth element on CollectVersion's tuple β widening that would rewrite a
signature MeshWeaver.Plugins already destructures into three), ServedModuleBytes carries it on
ServedModule.Natives from the shelf AND out of a sealed publication, and the download route writes
the section back into the bundle it composes. A registry whose shelf holds the engine and serves a
bundle without it would hand every consumer downstream a module that lands, loads, renders β and
throws at the first P/Invoke.
The container derivation. ContainerReferenceSet now reads the native contributions of the
image's own deps.json, PrivateClosure carries them as NativeRides, ProjectBuild lays them out
under the pack input and names them in module-natives.txt, and the pack lane declares them with
--with-native. π¨ Two deps.json shapes, and the fleet's images are the second β measured
2026-09-15 on a real SQLitePCLRaw.lib.e_sqlite3 publish: a PORTABLE publish leaves 29
runtimeTargets entries and a runtimes/ tree on disk, while -r linux-x64 (what every MeshWeaver
image is) resolves them into a one-entry native section whose KEY is still
runtimes/linux-x64/native/libe_sqlite3.so while the FILE sits FLAT beside the app. Reading one
shape only answers "this image has no natives" about an image that has them.
π¨ And Resolve now treats a native-only package as SUPPLIED. It matched a package by the
ASSEMBLY it contributes, falling back to <id>.dll, so a package contributing only natives answered
Supplied = false against an image that ships its engine β and ProjectBuild reds an unresolved
PackageReference. That is why a build: container module could not even DECLARE the CVE-patched
engine (SQLitePCLRaw.lib.e_sqlite3 3.53.3, GHSA-2m69-gcr7-jv3q), and why
MeshWeaver.AppleMessages carried a conditional pin.
π¨ --with-native takes the OPPOSITE argument to --with, deliberately. --with refuses a path
component because the flat closure has no place for one; --with-native requires the path, because
the path is what the loader probes. Two flags, and two builder manifests (module-libs.txt,
module-natives.txt), because a lane feeding one to the other would be wrong whichever way it was
written. The native manifest's ABSENCE is a statement β this module ships no engine β and the pack
lane refuses a declared native with no builder provenance, exactly as it does for a non-MeshWeaver
assembly.
π¨ The gate and publish-bake lanes compose into /ext/modules/<Name>/, one level deeper than
before. ModuleNativeAssets accepts a directory as a module folder only when its PARENT is named
modules β the shape ModuleLandingService writes and the image publish lays out β so a lane
composing at the shallower path would land a module's natives on disk and never probe them. Both
lanes' real steps are EXECUTED against real bundle shapes by
.github/scripts/test-module-asset-landing.py, which asserts the bytes, the exact four segments AND
the depth, with a falsification arm per half.
What is NOT done:
- The container lane NAMES both shapes, and does not refuse β #4445. Its derivation used to
drop them WITHOUT A LINE:
NativeContributions.DeclaredByfiltered an unprobed native out (from the image'snativesection and from the shelf'sruntimeTargets) and reported it nowhere, and the module-libraries shelf β a portable publish β had its RID-specific managed assets read past (onlyruntimeis read).NativeContributions.UncarriedBynow names both,PrivateClosurecarries them (Result.Uncarried) and the builder prints onewarning:line each, in the SAME finding wording as the SDK refusal (UncarriedAssetFindings, one spelling inMeshWeaver.Plugin.Packaging), so one grep measures both lanes. The IMAGE cannot drop a RID-specific managed asset β it is a RID-specific publish, so its RID's copy is already resolved intoruntimeand rides. Arming a refusal here is #4445, on the same criterion #4367 was armed on: a full container wave with zero occurrences, and no live victim. - The container lane carries ONE RID's engine, because it resolves against ONE image. A host on another RID falls back to the runtime's own probing β strictly better than the nothing it carried before, and stated in the builder's log rather than left to be deduced. The SDK lane, deriving from a portable publish, carries every RID the package ships.
- The module-libraries shelf answers about natives (
DeclaredNativesOf/NativeFileFor), but no curated package declares one yet, so that half is built and unexercised.
The one native family the fleet ships through the registry today (SkiaSharp, for
MeshWeaver.Markdown.Export) still reaches consumers through the PORTAL HOST β the
"host happens to have it" shape the 2026-09-01 What's New called a defect for managed assemblies.
Nothing forces it off that route yet; what changed is that a module can now bring its own.
π¨ Does the new section need a FORMAT VERSION? No β and that is measured, not reasoned
A bundle is read by portals running older images. The registry serves one set of bytes to every
installation, so "the format gained a section" is only safe if an older reader ignores it β and
until ABundleCarryingNativesIsReadUnchangedByAPreNativesReader (BundleReaderTest) nothing
asserted that. It is not something a producer-side test can reach: the property belongs to the code
that is not in this build.
Verdict: additive. No format version, no manifest schemaVersion, no dual-write. It rests on two
independent facts, both pinned by that test rather than argued:
| why an older reader is unaffected | |
|---|---|
| the archive | the three sections are prefix-disjoint. Every pre-#4126 consumer of the flat folder filters meshweaver/modules/ with no / in the remainder (ServedModuleBytes, PublishedBundleCatalogue) and the asset consumer filters meshweaver/moduleassets/. A native is under neither prefix, so an older reader does not skip it, mis-file it or fail on it β it never enumerates it |
| the manifest | BundleReader deserializes with JsonSerializerDefaults.Web, whose UnmappedMemberHandling is Skip. An older ModuleRef β which has no nativeAssets property at all β reads every other field unchanged. No reader on the bundle path uses Disallow |
π¨ What WOULD have needed a format version, and is exactly why the section is its own:
- under
meshweaver/modules/β that changes the folder's meaning. An older reader's flat filter silently drops the natives while the producer believes it shipped them: bytes that read as shipped and behave as absent, with nothing anywhere to grep. - under
meshweaver/moduleassets/β an older reader would lay a loadable binary intowwwrootand serve it over HTTP.
The test drives the real writer and reader, and it carries its own anti-vacuity controls, each
falsified: a bundle with no native fails Assert.Single on the entry; a manifest that does not
declare nativeAssets fails the Disallow control (which exists to prove the JSON really
carries the unmapped member, rather than the Skip assertion passing over a document with nothing
new in it); and writing the native inside meshweaver/modules/ β the change-of-meaning case β
fails the prefix-disjointness assertion.
One incidental fact the test had to learn, worth keeping: the manifest entry is written with a
UTF-8 BOM, and Utf8JsonReader skips one over a stream but not over a span. Reading the
entry's bytes directly fails with '0xEF' is an invalid start of a value. BundleReader uses the
stream overload; anything reading the manifest must do the same.
The other closure derivation β the container lane β used to drop runtimeTargets, and that was a
policy line rather than a data limit: ContainerReferenceSet read only the runtime section of
the image's deps.json while runtimeTargets sat in the same document, and the refusal of a
native-only PackageReference was Resolve matching a package by the ASSEMBLY it contributes.
Both are closed β see "LANDED, RE-SERVED, and derived by BOTH lanes" above.
The bundle lane β modules as Store packages (#1664)
A compiled module reaches a deployment one of two ways: shipped in the image (the baseline above), or installed from the Store as part of an ordinary package. The second rides the plugin bundle transport end to end β there is deliberately no second distribution channel:
- Declare β the package's root
index.jsoncarriescontent.modulenaming the module's entry-assembly ("module": "MeshWeaver.Social"), plus the platform floor it requires in thecontent.minMeshVersionfield authors already write. The listing reads both onto the catalog entry (PackageManifest.Module/.MinMeshVersion) and the ordinary install-record stamp carries them onto the record. A package with content nodes AND a module is one Store product β card, price, install funnel, pre-install eligibility all unchanged. - Build β
MeshWeaver.Plugin.Build'smodule-packmode packs a built module's closure into a bundle recording theminMeshVersionfloor (--min-mesh-version) and β required, not diagnostic, since #3211 β the identity of the anchor assembly the module was compiled against (MeshWeaver.Compiler.dll, #1707), named with--graph-dllor stated with--framework-mvid. A pack that can supply neither exits 2 rather than writing a bundle whose consumers can never tell a rebuild from a no-op. π¨ Since #3554 the lane also asserts that the declared floor is SATISFIABLE by the platform the bundle is compiled against β a floor above it can never be met by any deployment that would adopt the bundle, and the runtime's hold cannot tell "not yet" from "never" (see Release Availability Gates). It is a plain dotnet invocation over an output folder, so ANY node repo's CI can drive it β SocialMedia builds its own module bundle the same way the platform repo does β and because the gate is the floor, ONE bundle serves every compatible platform build: nothing is rebundled per CI build. The closure is an explicit statement (--with), never a folder scrape: a publish output contains the whole app closure, and bundling framework assemblies would shadow the platform at the consumer. - Serve β the registry portal's
/api/plugins/bundlesserves the module section inside the SAME bundle that carries the package's NodeType assemblies (meshweaver/modules/besidemeshweaver/assemblies/, one manifest naming both). The registry serves a module's bytes from its ownmodules/<name>/tree β the very bytes it loads and runs β and refuses to serve a landing its own boot would skip (uninstalled, or a floor the registry's own platform no longer satisfies). The index stamps each bundle'smodule(and its floor) only when the bytes are actually servable, so a consumer never downloads for a section that will not be there. Same instance-key auth, fail-closed. - Land β on install (and on update), a consumer whose package declares a module fetches the
bundle, verifies the platform floor (
ModulePlatformFloor.DeclineReasonβ the one notion of the module platform requirement, checked at the index, at the manifest, and again at placement), and lands it throughModuleLandingServiceintomodules/<name>/with its activation entry (version + floor recorded, plus the framework identity the registry advertised for those bytes β the producer's value, which the update decision reads back). The landing GATE is deliberately not MVID equality β that is bake semantics, the NodeType lane's gate: a module binds by simple name, so a bundle built against an older platform installs ex post on any deployment satisfying its floor. Restart-as-activation as above:PendingRestartis the signal, the next restart loads it.
Auto-update
Store-installed modules update themselves by default, and since #3650 they do so eagerly β
rule R3 of the Module Adoption Policy (Doc/Architecture/ModuleAdoptionPolicy): as soon as a new
module version ships, we start using it. The reconcile (RegistryUpdateReconciler) runs a module
pass after the content pass β at boot, the moment the registry broadcasts that a module was
published (for that one package), and every 30 minutes as a safety net
(Plugin Update on Green Build). For every installed
module-declaring package it consults the registry's bundle index and applies the one pure decision
(ModuleUpdateDecision): a newer version lands via ModuleLandingService and flags
PendingRestart; the same served version built against the same framework is skipped without a
download; a bundle's declared floor is an advisory worded into the log, never a skip (#3648) β
whether the bytes load is measured by the link probe at placement. Nothing is ever rolled back
unattended.
The restart happens, too. A landed generation loads only at a restart, and that restart used to
be whatever platform roll came next. The self-updater now reads the activation record after the
platform half of every check that patched nothing and, when PendingRestart is raised, rolls the
workloads on the image they run (IDeploymentUpdater.RestartAsync) β paced by
SelfUpdate:MinRollInterval exactly like a roll, because a restart drops the same live circuits. A
landing wave this process ends (ModuleLandingService.ModuleSetProposed) triggers the check
directly, so the restart follows the landing by the coalesce window plus the floor, not by the next
unrelated publication. An install that cannot restart itself reports RestartUnavailable on the
Updates tab, naming the operator's move.
A fallback is re-examined. When the newest landed generation could not be loaded and the previous
one runs (the keep-the-old fallback, #3649), the boot that
falls back writes the marker the reconcile re-examines: for every store entry the loader was handed,
ModuleLoadabilityRecorder reads the FallbackModule / IncompatibleModule records back onto the
generation the loader tried and writes that module's marker file β activation.d/<Name>.unloadable,
the head's generation, its framework identity and the refusal β or deletes it when the head loaded. A
create and a delete, never a read-modify-write of the entry a landing on another replica may be
replacing (#2090). ModuleActivationSidecar.Read attaches the identity to the entry
(ModuleActivationEntry.UnloadableFrameworkMvid, never stored in the entry file) only while the entry
still heads the generation the marker measured, so a landing that moves the head on retires a stale
marker without touching it. It is the boot's measurement rather than the module set's adoption record
(ModuleSetIndex.FallbackGenerations) on purpose: that record is written once per set by the first
replica to adopt it and survives a platform roll unchanged, so it can report a fallback the running
image no longer takes; every boot rewrites the marker. The same-version branch of the decision then
asks the one question such a deployment has: does the registry serve a different build of this
version than the one that would not load? It lands when it does β a build for this platform
appeared β and answers SkipUnloadable (never "already landed") when the registry still serves the
build that was refused.
"Already landed" means this content against this FRAMEWORK
π¨ A module's version encodes its CONTENT only. Rebuild the same source against a new platform
and it republishes under the same version β so a reconcile that compared the version alone
answered "already landed" for an artifact the deployment does not hold, and nothing ever looked
again (Plugins#931). Measured in Plugins#723: after a platform identity flip the updater landed the
~12 modules whose versions had moved and then went quiet with no new MeshWeaver.AI.OpenAI build,
because OpenAI's had not; rolling the image anyway crash-looped deterministically (the pre-flip
build cannot resolve ProviderModelLister on the new platform, whose registration had moved) and
the fleet was held on an old image.
So the skip is keyed on (version, framework identity). The registry records the identity of a
module's bytes when their owning repo's CI publishes them (ModulePublish β ShelveModule) and
advertises it per bundle on the index (BundleRef.FrameworkMvid β never the index's top-level
identity, which is the registry's own bake and says nothing about a module it did not build); the
consumer records what it landed on the activation entry and compares the two before downloading a
byte.
The two sides are deliberately not symmetric, and that asymmetry is what stops the fix becoming a download loop:
| landed | served | verdict |
|---|---|---|
| known | known, different | Land β same content, different platform build. The reason names both identities. |
| unknown (entry predates the field) | known | Land, once. The landing writes the identity back, so the next reconcile has two known values. |
| any | unknown | Skip, and the reason SAYS the identity could not be checked. Landing could never turn "the registry states nothing" into evidence β it would state nothing next time too β so answering Land there re-downloads every module on every reconcile, forever, against any registry that predates the field. |
| known | known, equal | Skip β the genuine no-op, with the framework named. |
Publication arrival order is not version order
The registry can receive one module from both the module repository's publication and a slower core
CD that baked an earlier repository commit. Both uploads are valid warehouse stock, but the one that
arrives last is not necessarily the newest. On 2026-09-11 MeshWeaver.Mail.MicrosoftGraph 1.7.0
landed at 02:49Z; core CD then delivered 1.6.1 at 03:00Z, the old last-writer rule moved the head to
1.6.1, and the next restart silently un-shipped the release (#3996).
ShelveModule therefore applies the consumer's no-unattended-rollback rule to the publish route: a
known older version lands its content-addressed generation but never displaces a newer head whose
bytes are present β whether or not that head links on the registry's own platform, because the shelf
warehouses modules for newer platforms. The older upload competes for the head's single fallback
slot instead, and the index lists head and retained fallback at their own versions, each download
resolving its own generation. A head whose entry assembly is missing is healed by the next valid
upload, and unknown versions keep the legacy behaviour. The full rule, the fallback choice, what a
deliberate rollback now means, and how the cross-replica case is closed (#4026 β a record per
landing, the head derived from them) are in Module Adoption Policy.
The remaining blind spot (a registry that states no identity) is closed where it is created, not
by churning consumers: a bundle that cannot say what it was built against must not be publishable.
That is #3211, and it matters more than it sounds β measured on MeshWeaver.Plugins run 33773265959
(2026-09-03), all 34 bundles packed built-against MVID (unrecorded), so on the day the
comparison shipped it had nothing to compare anywhere in the fleet. The producing lane now refuses
three times over: module-pack will not write a bundle with no identity, the pack step names the
anchor assembly explicitly and is RED when it is not there, and the hand-over refuses to POST
bytes whose manifest states none. See
Module Build Architecture β "A bundle states what it
was built against" for the producer half.
The policy gate is the deployment's existing update policy β Admin/UpdatePolicy, the same
single surface that governs the platform image roll; there is no module-specific knob.
Continuous lands unattended; Stable β the platform default since 2026-09-08 β and None
(what an absent policy reads as, #3542) decline the UPGRADE (the catalog's manual Update still
works there). The record's version pattern governs the platform IMAGE only β modules carry their
own versions β so a Continuous record without a pattern still lands modules while its platform
stays on clean releases. A
deployment that pins its image takes updates deliberately, and its modules do not run ahead of
that choice. A first landing is deliberately policy-exempt: it completes an install the
operator's own surfaces already sanctioned, and gating it would ship a package whose binary half
never arrives. The wiring is IModuleUpdatePolicy (MeshWeaver.PluginCatalog), implemented by
the memex portals over the policy node; a host that registers no implementation gets the default
(allowed).
Deciding: what can be a module, and where its source may live
Two properties decide a module's shape, and they are independent β they move in separate changes, and confusing them is what makes a carve-out look blocked when it is not, or land when it should not have.
| question | answer decided by | |
|---|---|---|
| Delivery | do the bits arrive in the IMAGE or from the REGISTRY? | whether the deployment can boot without it |
| Source | does the code live in the PLATFORM repo or a NODE repo? | whether the platform's bake host must compile against it |
Delivery β image closure vs registry bundle
Registry delivery is the default for anything a deployment can start without. The exceptions are structural, not preferences:
- Storage backends must be image-shipped: a store-installed module needs storage to already work, so the thing that provides storage cannot itself arrive through it.
- Auth schemes and anything with middleware-ORDER significance must be image-shipped: the pipeline is composed at boot, before any install has run.
- The loader itself and the persistence contracts it reads.
Everything else can be registry-served, and the switch between the two lanes is one line per host:
a module in the image closure is listed under Modules:Assemblies; a module from the registry is
listed under Modules:Required and installed by its Store entry (preInstalled for the ones a
first-party deployment must not be without).
π¨ The two lists are mutually exclusive for one name, and the exclusion is enforced, not advisory.
ComputeEffectiveModuleEntries takes the baseline first and dedupes the persisted entry away by
name, so a leftover Modules:Assemblies line SHADOWS a landed store module β the deployment binds
an app-closure copy that a later image may not even ship. On the install side the landing service
answers 409 while any host still carries the same-named DLL in its closure. So flipping a module
from image to registry means dropping the ProjectReference and the baseline entry in the same
change set that publishes the Store entry.
Source β platform repo vs node repo
A module's source may live in a node repo only when nothing the platform's own bake host must compile depends on it.
The bake host (tools/MeshWeaver.PluginTester) compiles the platform's gated content β the sample
trees .github/scripts/stage-samples-gate.sh stages β and it builds what it needs from the
platform checkout. It can therefore land a module the way a portal does, from a tester-local
MeshModuleClosure row, only for as long as that module's source is still in the platform tree.
This gives the ordering rule for any carve-out:
A module's delivery flip β out of the image, out of the canonical content surface β can happen while its source is still in the platform repo. Its source move cannot, until the bake host consumes a node-repo-BUILT bundle instead of building from source.
Two live examples of each side of that line: the AI engine has flipped delivery (registry-served,
Modules:Required) while src/MeshWeaver.AI remains in the platform repo, because the gate still
builds it there. MeshWeaver.Maps cannot move its source at all yet, because gated sample content
(Cornerstone/Pricing) uses MapControl/MapMarker and the gate has no other way to obtain the
assembly.
The canonical content surface follows delivery, not source
FrameworkBuildIdentity.ContentSurfaceAssemblies is the set in-mesh content may compile against,
and it is defined as the bake host's transitive MeshWeaver.* closure. When a module leaves the
image it leaves that set too β and three things must move together, or hosts fork their identity:
- the name comes out of
ContentSurfaceAssembliesand out of the bake host's reference closure (the equality between the two is asserted byFrameworkBuildIdentityTest.CanonicalList_MatchesTheTesterClosure, which recomputes the closure from the csproj graph β never satisfy it by editing the list alone); - the bake host gains the tester-local
MeshModuleClosurerow so content that references the module still compiles (CompileReferences.ComposeWithModulesputs installed modules into the reference set); - anything that arrived transitively through the removed reference and is still content surface gets re-anchored directly β dropping one reference drops everything it pulled in.
Modules and the in-mesh compiler
In-mesh source compiles against the platform's TRUSTED_PLATFORM_ASSEMBLIES plus this mesh's
installed modules: InstallAssemblies records every loaded module as an
InstalledModuleAssembly DI singleton, and MeshNodeCompilationService composes its reference
set from both β so a module published outside the app closure stays visible to scope classes and
NodeType source that reference it (e.g. a map control). Two boundaries stand:
- Kernel cells β the pack-scripting seam (#1649). Executable
--rendercells compose their reference set per SESSION, not from the frozen process snapshot alone: every installed module joins automatically (MeshScriptEnvironment.SessionAssembliesenumerates theInstalledModuleAssemblyregistrations β modules are Default-ALC file-backed, so the runtime bind is free), and a dynamic NodeType joins by DECLARING it βcellSurface: truein its definition (the pack'sindex.json). At session init the kernel resolves each cell-surface type's CURRENT baked assembly through the assembly store + compilation cache, references its PE, and binds its collectible load context by name β scoped to the session's declared set, never a blanket hook. Assemblies in collectible load contexts never enter the frozen snapshot, so the cell surface is a declaration, not a load-order lottery. Two rules follow: acellSurfaceNodeType'sSource/is single-home β any other NodeType thatshared=-consumes it fails its compile with a message naming the owner (the CS0433 duplicate-type class, prevented by construction); and a live session pins the generation it bound β sessions are short-lived, and a recompile mid-session keeps old sessions on the old generation while new sessions bind the new one (the same semantics live layout areas have). - The bake fingerprint is DECISIVE. Every successful NodeType compile stamps
CompiledModulesHashβ a hash of the sorted installed-module MVIDs (InstalledModulesFingerprint) β besideCompiledFrameworkVersion, and the usable-build check (HasUsableBuild) invalidates a build stamped with a DIFFERENT non-null hash than the live set, while its rebuild-kickoff twin (HasStaleFrameworkBuild) re-drives the compile for it. That is what makes a module-only update safe: a store install lands new module MVIDs without changing the framework MVID, and baked builds that could reference the replaced module rebuild on the next boot instead of throwingMissingMethodExceptionat activation. Definitions stamped before the feature carrynull, which compares as MATCH β such builds predate modules in the compile surface and stay governed by the framework rule; call sites without a mesh in scope pass no hash and likewise keep the framework-only behavior.
Related
UI contributed as data (menus, settings tabs, whole top-bar menus β UiContribution nodes) is
UI Extensibility. Content plugins and their registry are
Plugins and Plugin Packaging.
Deployment surfaces: Feature Flags Β·
Environment Composition Β·
Deployment.
Modules and composition are different axes, deliberately. Which compiled ASSEMBLIES a deployment
loads is Modules:Assemblies (plus the persisted store installs above) β decided before the DI
container exists, so it cannot be a mesh-level decision. Which CONTENT PACKAGES an environment
carries is Environment Composition's Features:Flags:*,
reconciled by the boot install pass. A Store package that carries a module rides both: its content
lands through the composition lane, its assemblies through the bundle lane above.