🚨 Rule change, 2026-09-07 (maintainer) β€” see Module Adoption Policy. A declared minMeshVersion no 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:

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:

  1. The Modules:Assemblies appsettings 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.

  2. 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 aggregate modules/activation.json is 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 denied on the write side (HTTP 409), and a FileNotFoundException on 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):

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:

  1. 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. CollectGarbage is 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.

  2. 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.

  3. A running process loads from PROCESS-LOCAL storage (ModuleGenerationPin). The shared modules/ 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 was FileNotFoundException: 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 β€” ConvergencePending is 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:

  1. a baseline Modules:Assemblies entry resolves through MeshBuilder.ResolveModulePath, whose probes are landed root β†’ image β†’ app closure;
  2. the landed probe looks in the fixed modules/<Name>/, which generation landing never writes, so it misses and the image copy wins;
  3. 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:

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:

  1. A satellite's lane pin and its platform pin move independently. MeshWeaver.Plugins calls the lane at @main while the pack TOOL is published from the resolved sealed platform-ref, which lags main. 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.
  2. github.sha is 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:

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:

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:

  1. The container lane NAMES both shapes, and does not refuse β€” #4445. Its derivation used to drop them WITHOUT A LINE: NativeContributions.DeclaredBy filtered an unprobed native out (from the image's native section and from the shelf's runtimeTargets) and reported it nowhere, and the module-libraries shelf β€” a portable publish β€” had its RID-specific managed assets read past (only runtime is read). NativeContributions.UncarriedBy now names both, PrivateClosure carries them (Result.Uncarried) and the builder prints one warning: line each, in the SAME finding wording as the SDK refusal (UncarriedAssetFindings, one spelling in MeshWeaver.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 into runtime and 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.
  2. 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.
  3. 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:

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:

  1. Declare β€” the package's root index.json carries content.module naming the module's entry-assembly ("module": "MeshWeaver.Social"), plus the platform floor it requires in the content.minMeshVersion field 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.
  2. Build β€” MeshWeaver.Plugin.Build's module-pack mode packs a built module's closure into a bundle recording the minMeshVersion floor (--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-dll or 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.
  3. Serve β€” the registry portal's /api/plugins/bundles serves the module section inside the SAME bundle that carries the package's NodeType assemblies (meshweaver/modules/ beside meshweaver/assemblies/, one manifest naming both). The registry serves a module's bytes from its own modules/<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's module (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.
  4. 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 through ModuleLandingService into modules/<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: PendingRestart is 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:

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:

  1. the name comes out of ContentSurfaceAssemblies and out of the bake host's reference closure (the equality between the two is asserted by FrameworkBuildIdentityTest.CanonicalList_MatchesTheTesterClosure, which recomputes the closure from the csproj graph β€” never satisfy it by editing the list alone);
  2. the bake host gains the tester-local MeshModuleClosure row so content that references the module still compiles (CompileReferences.ComposeWithModules puts installed modules into the reference set);
  3. 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:

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.

Reconnecting…
The connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.