Carving Projects Out Of Core

The platform repo is being emptied of everything that is not platform. This page is the map: what is left, what actually pins each thing in place, and β€” the distinction that causes the most wasted work β€” which of the two different moves you are making.

Measured against Systemorph/MeshWeaver@98195f9b2 and Systemorph/MeshWeaver.Plugins@79271b94. Re-measure before acting on it; the numbers move weekly.

🚨 Every move leaves an edge behind, and the edge points the WRONG way. What core still reaches back INTO the plugin repo for β€” the image lanes that publish the departed portal host, the gate whose subject stayed here while the test left β€” is inventoried in Repository Dependency Direction. Read it before making the next move; a move that adds an unpinned coreβ†’plugins edge has not finished.

The two moves are not the same move

Almost every argument about "can this leave core" is really two questions wearing one name.

SOURCE move MODULE move
What happens the project's source moves to MeshWeaver.Plugins/src/ the project becomes a registry-served plugin bundle
Who compiles it the plugins repo's own hosts, into the portal image the module-pack lane, against the image
Where it ends up at runtime /app β€” exactly where it was before modules/<Name>/, delivered by the registry
Blocked by any consumer left in core: src/, test/, tools/, memex/ a host ProjectReference β€” an image copy plus a registry module of one name is a 409
Changes delivery? no yes

Wave 1 (storage, gRPC, SignalR, InstanceSync, Migration, LocalMesh) was entirely SOURCE moves. The projects still ship in the image; only their source lives elsewhere. Nothing about the plugin registry was involved.

🚨 A source move does not need the module lane to be able to build the project, and a module move is not blocked by the lane's limitations either way. Conflating them is why "we cannot move X until the build supports Y" has been said about projects the build was never going to touch.

What a source move costs, exactly once

🚨 Nothing, since NuGet publication was retired. It used to cost the project its package: core's release lane ran dotnet pack solution-wide with no IsPackable=false, so every src/ project shipped to nuget.org on a version tag, while the private plugins repo delivered bundles and no packages. That is still visible in the published versions β€” every wave-1 project (MeshWeaver.Hosting.PostgreSql, .Hosting.Grpc, .InstanceSync, .Speech.Contract, .Social) is frozen at 3.0.0-rc7, the release in which it moved, while everything still in core stopped at rc9.

Both the lane and the rc line it ran on are gone (Release Process & Versioning Β§5): nothing in the fleet restores a MeshWeaver package, in-mesh source compiles against the platform image, module bundles carry their own closures, and satellites build inside mw-plugin-test. The packages on nuget.org stay listed as history. So do not price a source move against a delivery channel that no longer exists β€” the version numbers above are an artefact of when each project left, not a cost anyone still pays.

What the container build lane did and did not unblock

The 2026-08/09 lane rebuild (the platform image is the compiler β€” see Module Build Architecture) removed the last technical reasons a module could not be built:

Together those convert MeshWeaver.GitSync and MeshWeaver.ContentCollections.Indexing from "cannot be a module" (their third-party closure had nowhere to live) into "can be".

🚨 What the lane did NOT change is what pins most of the remaining projects. They were held by consumers inside core β€” above all by memex/Memex.Portal.Shared β€” and no build change can release those. Only deleting the reference can.

The keystone: Memex.Portal.Shared β€” moved to plugins

78 files, ~17.3k lines β€” Api, Authentication, Email, Pages, SelfUpdate, Seo, Settings, Social, Storage β€” that sat in core while all four hosts that consume it (Memex.Portal.Gui, .Distributed, .Monolith, Memex.LocalMesh) lived in plugins. It is now a SOURCE move to MeshWeaver.Plugins/src/Memex.Portal.Shared, with its suite at src/Memex.Portal.Shared.Test; the assembly name is unchanged and it still ships in the portal image.

It alone holds 13 core ProjectReferences β€” but most of them are not real. Measured: it builds clean without Observability.Contract, Maps, ContentCollections.Indexing and .Indexing.Graph. The one candidate it genuinely couples to is GitSync, through four files β€” MemexConfiguration plus the GitHub login, connect and webhook endpoints. That is authentication, which is precisely why an earlier attempt to move Portal.Shared wholesale was split instead: doing it exiles sign-in from the platform repo.

Decided (core slimming): GitHub sign-in moves WITH Portal.Shared. MemexConfiguration and Social/GitHub*Endpoints live in plugins now and still call core's MeshWeaver.GitSync library through a $(MeshWeaverRoot) project reference; GitSync itself stays in core for now and moves in a later step. So sign-in is no longer the platform repo's: the platform keeps the library the endpoints call, and plugins owns the portal surface that signs a person in.

So the keystone was narrower than it looked: it gated GitSync, and it never gated Maps or Indexing.

What the move carried with it, and what it left unguarded

The second-order pin behind it is tools/MeshWeaver.PluginTester β€” core-owned, shipped as the gate image β€” which directly references Indexing.Graph, GitSync, Maps, Mesh.Operations and PluginCatalog. Since it gained --module those references are convertible rather than fatal, but each has to be converted deliberately.

The real boundary is the CONTENT SURFACE

The .csproj graph is not what decides whether a project can leave cheaply. FrameworkBuildIdentity.ContentSurfaceAssemblies is. It lists the 26 assemblies that are the closure of the content gate (tools/MeshWeaver.PluginTester). That makes it the set shipped content is PROVEN to compile against: the bake host compiles against that closure plus the modules it lands, so content that binds anything else does not pass the gate. Their reference-assembly hashes make up the surface identity s<hash>.

🚨 The surface identity is no longer the adoption key. Adoption keys on FrameworkBuildIdentity.FrameworkVersion, which is the compatibility key c<major>e<epoch> (PlatformCompatibility.KeyOf, policy platform-backwards-compatibility). The s<hash> is FrameworkBuildIdentity.BuildProvenance: it is logged and recorded, and it never causes a cache miss. So a change to a content-surface assembly's public surface does not by itself invalidate any bake or re-bake any portal. Earlier versions of this page said it did. That was true while the surface hash was the key, and it no longer is.

What removing a public type IS: a binary break, judged by The Platform Compatibility Ladder. Its pull-request check links the DEPLOYED plugin set against the candidate platform, and its break catalogue (ModulePlatformMemberLinkTest) pins what each kind of change does to a compiled plugin. A removal that a published plugin still binds is red, and it has exactly three answers:

  1. Keep it compatible: an [Obsolete] forwarder under the old signature, or a [TypeForwardedTo] to an assembly the platform still ships. Core cannot forward to a plugins assembly, so a type that leaves for a module cannot use the second form.
  2. No published plugin binds it any more: the plugin side moves to the new home and republishes FIRST, and the core removal lands after. This is the deleting-half-last order that Pairs-with: already enforces.
  3. A DECLARED epoch bump: "epoch" plus a "breaks" entry in src/MeshWeaver.Compiler/platform-compatibility.json. This is the real fleet-wide event: a new compatibility key, so every plugin must rebuild before its bytes are adopted again. It is a delivery event for the owner to schedule. Never treat it as a side effect of a refactor.

🚨 The ladder cannot see reflection or string-named binding (Type.GetType("…")). A removal reached that way links green and fails at runtime, so search for it by hand (see Graph/Apps below).

🚨 The pull-request ladder links only FOUR plugins. Its input is .github/scripts/fetch-deployed-plugin-set.sh, whose modules= list is fixed: MeshWeaver.AI, .Markdown.Collaboration, .Maps and .Payments.Stripe. Every other published module is outside the check. That includes the Mail.MicrosoftGraph, Mail.Gmail and OgCard modules, and also Memex.Portal.Shared, which ships in the image rather than as a module. So a removal that only those consumers bind leaves the ladder green with nothing checked. A green ladder is evidence only for the four. A carve that removes types any other plugin binds needs its own compatibility check of those consumers:

Answer 2 above is then shown by that check, not by the gate. (Measured on the pull-request lane, dotnet-test.yml. Whether the promoted-image rung in main-cd.yml reads a wider set was not measured here.)

Both projects moved in the first wave were OUTSIDE the content surface, and that is why they were free. Nothing in any mesh bound MeshWeaver.Hosting.Embeddings or MeshWeaver.Observability.Contract, so the move was a .csproj edit.

🚨 Every remaining candidate is INSIDE the content surface, and leaving it means copying a precedent rather than improvising. MeshWeaver.AI and MeshWeaver.Markdown.Collaboration have both already made the trip, and the comment in FrameworkBuildIdentity states the shape: the project becomes a MODULE, leaves the list, is composed into content compiles per-mesh via CompileReferences.ComposeWithModules, and is made required by a pre-installed package so every portal still lands it. What that costs is the ladder's question above: which published plugins bind the departing types, and which of the three answers each of them gets.

The remaining core projects, and what pins each

Project Lines Content surface? What pins it to core Verdict
Hosting.Embeddings 471 no nothing at all moved out β€” free
Observability.Contract 1 320 no a ships-the-bits reference + one test project moved out β€” free
Maps 116 yes in-mesh sample content (Cornerstone/Pricing), 5 test meshes, PluginTester module; answer the ladder (see above)
ContentCollections.Indexing 1 851 yes one using in Mesh.Operations; in-mesh content module; answer the ladder (see above)
ContentCollections.Indexing.Graph 2 253 yes PluginTester; in-mesh content with the above
GitSync 21 917 yes PluginCatalog (13 files), Mesh.Operations, PluginTester/ComboAssembler/ComboVerifier, the portal hosts in plugins, and in-mesh content in five plugin packages β€” see the next section stays until PluginCatalog reaches it through a seam; then module, answering the ladder; auth β‡’ Modules:Required
PluginCatalog 21 245 yes PluginTester, ComboAssembler/Verifier stays β€” it is the plugin system
Documentation 1 670 no the doc gate; settled decision stays
Mesh.Operations 4 726 yes PluginTester (Portal.Shared and LocalMesh consume it from plugins) stays

Everything not listed is kernel: messaging, data, layout, the mesh contract, hosting, the compiler and NuGet resolution, packaging.

The ProjectReference graph lies about all of them

🚨 Four of the references that appear to pin these projects turned out to carry no code dependency at all β€” Memex.Portal.Shared builds Release -warnaserror with 0 Warning(s) after dropping Observability.Contract, Maps, ContentCollections.Indexing and .Indexing.Graph. They exist to put the assembly in the app closure, nothing more.

And the reference that actually matters is not in any .csproj: samples/Graph/Data/Cornerstone/Pricing/Source/PricingLayoutAreas.cs binds MapControl, and it is a Source/*.cs mesh node β€” it compiles at RUNTIME in the portal, never in CI. That is why five core test projects reference MeshWeaver.Maps while not one line of their own C# mentions it: their test meshes compile that sample.

So neither direction of the .csproj graph can be trusted here. A reference may be dead weight, and a live consumer may have no reference at all. Check both: a warnings-as-errors build of the consumer settles the first; the content trees and the node JSON settle the second.

Feature folders inside content-surface projects

The table above is per project. A second survey looked one level down, at feature subsystems that sit as folders inside a content-surface assembly and whose names match something MeshWeaver.Plugins already ships. Removing any one of them removes public types from a content-surface assembly. Under the ladder above, that is a binary break for every published plugin that still binds them. The s<hash> changes too, but only as provenance. So the order is: the plugin side moves and republishes first, the core removal lands after, and an epoch bump is only for a break that cannot be answered any other way. Batching is still worth it, but the reason is different. If moves must end in a declared break, one bump costs the fleet one rebuild instead of several.

Measured against Systemorph/MeshWeaver@4cc5c17fbf and Systemorph/MeshWeaver.Plugins@f1752c83b.

Folder Lines What Plugins has Who reaches it Verdict
Mesh.Contract/Threading 4 993 MeshWeaver.Threading.Test, a test project only everything: it is IIoPool, PromiseCache, AlcLeaseRegistry and the teardown signal (namespace MeshWeaver.Mesh.Threading) stays β€” kernel. The name only looked like a feature
Mesh.Contract/Payments 2 222 MeshWeaver.Payments.Stripe, the implementer (a second half, not a duplicate) the Store package's in-mesh sources (Store/Order, CouponClaim, Catalog, Maintenance), Hosting/Deployment, the Stripe module stays β€” it is the seam. It was put in core so commerce content compiles on a portal that mounts no payment module (The Payment Provider Contract). As a module it would bring back the CS0234 it was created to remove
Hosting/SelfUpdate 790 MeshWeaver.SelfUpdate.Aks, the implementer (second half) PluginCatalog.RegistryUpdateReconciler (SelfUpdateOptions.HostOf, the one registry-host rule), Deployment.Contract, core's Memex.Portal.ServiceDefaults health check, Memex.Portal.Shared stays β€” interfaces plus options that core code calls; the provider half has already left
Graph/Apps 1 401 nothing of that name the kernel home page (UserActivityLayoutAreas β†’ HomeAppSource), GraphConfigurationExtensions.AddAppDirectory, in-mesh content in AppleMaps/AppleWeather/Edu/Store/Core stays β€” the kernel calls into it, and AppleMaps/Maps/Source/AmapsConnect.cs reflects on "MeshWeaver.Graph.Apps.HomeAppSource, MeshWeaver.Graph" by assembly-qualified name, which a move would break silently
Graph/ControlLane 2 940 nothing of that name; consumers only image assemblies Memex.Portal.Shared (endpoints, settings tab, self-update hand-over) and MeshWeaver.Hosting.Instance; in-mesh sources in Hosting/InstanceAction, Hosting/Deployment, Hosting/PlatformBuildInbox, Approvals/Elsewhere stays β€” see below
Graph/OpenGraph 668 MeshWeaver.OgCard, a consumer (second half) GraphConfigurationExtensions registers it on every mesh; Memex.Portal.Shared (SeoEndpoints, OgCardRenderer) and the OgCard module call it stays for now β€” see below
Graph/MailCopy 3 379 consumers only: the Mail.MicrosoftGraph and Mail.Gmail modules those two modules; core's compile-time impersonation table names its types; no core src/ caller, no in-mesh binder in either repo's content trees move, once its home is decided β€” see below

Why ControlLane and OpenGraph stay

Both have an image assembly and a module or in-mesh source among their consumers. Two questions about a new home are easy to conflate. Keep them apart:

Identity membership is a third question, and since the compatibility key it decides nothing about adoption (see above).

So for each candidate home, ask whether BOTH the portal's references and the bake host's references carry it:

Neither home satisfies all of ControlLane's consumers, so it stays in the content surface. Not established: MeshWeaver.Hosting.Instance is described both as shipping in the image and as the Hosting.Instance package's module. Whether the bake host lands it, which would make it a possible home, needs a trial bake, not a reading.

OpenGraph is smaller and has no in-mesh binder. A SOURCE move into a plugins image project (beside Memex.Portal.Shared/Seo/OgCardRenderer) would work, because a module may reference an image-shipped plugins project (Mail.MicrosoftGraph references MeshWeaver.Blazor). But the OgCard module would then bind the portal host assembly. The module's own declaration also says the fetcher "stays a core singleton": it is the SSRF-guarded outbound fetch (http(s) only, loopback and private addresses refused). Its two callers are both published plugin bytes (the OgCard module, and Memex.Portal.Shared in the image), so the move would need answer 2 of the ladder for each, for 668 lines. Reconsider it when another carve out of MeshWeaver.Graph is sequencing the same republish anyway.

MailCopy: movable, but it has no home yet

MailCopy is the one subsystem with no caller in core and no in-mesh binder. Its consumers are two plugin modules, and the core-side ties are both data, not code:

What blocks the move is where it goes. Every plugin package declares exactly one module, and both consumers need this code:

  1. A new provider-neutral package whose module carries MailCopy, required by Mail and Google. This is the clean shape, but a new package in MeshWeaver.Plugins needs the owner's explicit go, because merging it publishes it.
  2. Fold it into the Mail module (MeshWeaver.Mail.MicrosoftGraph). Mail.Gmail then references that project, and Google requires Mail. Mail is pre-installed, so no portal installs anything new. The cost is that the Gmail module binds the Outlook module.

Until that choice is made the folder stays. Once it is made, MailCopy is the only member of this survey that can move, and it moves without a declared break.

Two defects this survey found

Both were the same shape: a move that was left half-finished, where the leftover looks exactly like a deliberate decision.

Hosting.Embeddings had no consumers at all

It was in MeshWeaver.slnx, built by core CI and published to nuget.org β€” and nothing in core referenced it. No .csproj, anywhere in src/, test/, tools/ or memex/. The only four mentions of the name in the whole repo were prose inside comments. Its three real consumers β€” MeshWeaver.AI.AzureFoundry, MeshWeaver.Hosting.PostgreSql, MeshWeaver.Hosting.Snowflake β€” were all in plugins already; it had simply not travelled with the storage backends in wave 1.

🚨 A project with zero consumers does not announce itself. It compiles, it packs, it publishes, and every CI signal stays green. Grep the .csproj graph, not the source text β€” the four textual mentions here would have read as "still in use" to any search that did not distinguish a reference from a sentence.

Observability.Contract existed twice, and had begun to drift

Both repos carried it β€” all twelve source files byte-identical, only the .csproj differing. Both fed the same portal image: Memex.Portal.Gui (plugins) referenced the plugins copy while Memex.Portal.Shared (core) referenced core's, and Portal.Gui references Portal.Shared. Two projects, one assembly name, one publish β€” precisely the same-identity duplicate that src/platform-shipped.txt exists to prevent, and which that file could not prevent because it assumes the name occurs once.

The tests had duplicated too, and had already diverged: UndiagnosableReportTest existed in both with the same 15 test names but 56 differing lines, the plugins copy strictly stronger (it exercised five real LogPipelineGap findings where core's exercised one, and asserted the fingerprint against the real report rather than a literal).

The defect was documented in the tree, in a comment, by whoever left it:

"the portal image builds BOTH β€” so the predicate has to exist in each, or the type is missing at runtime whenever the other copy wins the copy-local step."

🚨 A hazard written down in a comment is not a mitigated hazard. That sentence describes a coin toss decided by copy-local ordering, and it sat in the file it describes, being true, for as long as both copies existed.

The rule that generalises from both

A cross-repo move is finished only when the source is DELETED from the origin. Until then the two copies are indistinguishable from a deliberate contract split, and the tell β€” a duplicated assembly name, or a project nothing references β€” is invisible to every gate the repo has: both halves compile, both are packed, and CI is green on all of it.

When you move a project, in the same change set:

  1. delete it from the origin repo and from MeshWeaver.slnx;
  2. remove every ProjectReference that pointed at the origin copy β€” including the ones that exist only to ship the bits, which are the easiest to mistake for a real dependency (a warnings-as-errors build of the consumer will tell you: if it still compiles clean, there was no code dependency);
  3. if the moved project still ships in the image, the pack lane now MEASURES that off the pinned image rather than taking your word for it, so no list entry is required β€” but the measurement is only as current as the image the lane pins, and it is not armed at all in a lane that pins no image, so state the move in the change set anyway. (src/platform-shipped.txt remains as a declaration the lane compares against and warns about; it decides nothing. See The Platform-Shipped Witness.)
  4. add it to the plugins host build list in ci.yml β€” it is explicit, not a glob, exactly so a moved project cannot silently stop being built;
  5. move its tests, and split any test that spans the boundary rather than moving it whole β€” see below.

Splitting a test that spans the boundary

CompileFailureReportOrderTest pinned one invariant with two halves: the platform's compile-failure report is ordered so the verdict survives truncation (a property of CompileDiagnostics), and that verdict does survive the watcher's real burst aggregation (which needs the observability contract). When the contract left, the test had a foot on each side of the boundary.

Three ways to resolve that, two of them wrong:

🚨 When a test spans a repo boundary, ask which repo's CI should go red when the subject regresses β€” and put each assertion there. A test that follows its instrument rather than its subject leaves the subject uncovered where it lives, and usually needs a visibility hole to compile once it arrives. If it will not split, that is a signal the seam is in the wrong place β€” not a licence to widen InternalsVisibleTo.

Moving a SAMPLE SPACE into a package

Content moves differently from code, and the difference is not obvious until it bites.

A sample partition in samples/Graph/Data/<Name> is a disk-loaded Space: its index.md carries NodeType: Space front matter and AddPartitionedFileSystemPersistence reads it off the file system. A plugin package is not that. No package in MeshWeaver.Plugins ships a Space β€” every one of the forty-odd package roots is Store/Plugin, and a plugin's partition is created by the Store/Provision node plus SystemInstall, with content arriving through that space's _GitSync.

So the conversion, not a copy:

  1. index.md becomes index.json. The front matter (name, category, description, icon) folds into the package declaration and the markdown body becomes content.body. Leaving both in place is not an option β€” index.md and index.json would each claim the package's own path.
  2. Declare what the content binds. Cornerstone's RiskMap view binds MapControl, so the package requires Maps. A content package's requires is not decoration; it is what makes the in-mesh compile resolve.
  3. 🚨 Leave _Access behind. A sample tree carries AccessAssignment nodes for its demo users. Shipping those in a package injects named-user grants into every mesh that installs it, and no other package does this β€” a synced space's access comes from provisioning. Tests get their grants from the shared test-user fixture instead. See the plugin-provisioning skill: hand-creating a Space grants its creator Admin, and every human-run git_hub_sync re-mints that grant, so the access shape is a security decision rather than a detail.
  4. The tests follow the CONTENT, not the subsystem. Cornerstone's suites came from three different platform test projects and became one project in the plugins repo, because what they share is the partition they load. Tests that merely mention the content in a comment stay where they are β€” moving them exports unrelated platform coverage.
  5. Staging becomes two globs. A suite that used to copy samples/Graph/** now assembles its fixture from two repos: the platform's tree for the sibling partitions, this repo's for the moved one. And any Add<Name>() helper in SampleDataExtensions leaves with the content β€” the moved suites call IncludePartition("<Name>"), the primitive it wrapped.

Deployment material: each piece goes where its consumer is

The deployment material under memex/aspire, deploy/aspire and deploy/aks is not one thing. Each piece is decided by who reads it. The guard that pinned a piece moves with it, and nothing is deleted in core until the receiving half has merged.

Piece Went to Why there
memex/aspire/Memex.Aspire.Hosting (packed as MeshWeaver.Aspire.Hosting.Memex) MeshWeaver.Plugins src/Memex.Aspire.Hosting Its only in-fleet consumer, Memex.AppHost, already lived there and pinned core's copy through $(MeshWeaverRoot); nothing in core read it. The package is published from there now (publish-aspire-package.yml). The guard TheAspireHostStatesTheStorageType became Plugins' Memex.Hosts.Test/AspireHostStatesTheStorageTypeTest, beside its image-side counterpart.
deploy/aspire/Memex.Deploy.AppHost MeshWeaver.Plugins src/Memex.Deploy.AppHost It builds only against the integration, so it goes where the integration goes.
deploy/aks/envs/example Systemorph/Memex deployments/aks/example/ It is the template for the environment folders, and every real one already lives in deployments/aks/<env>/ there. The secret-template guard became Memex's scripts/check-example-secret-template.py, in its required Build (Release).
deploy/aks/infra (AKS platform Bicep) Systemorph/Memex infra/platform/ Estate provisioning already lives there (infra/estate.bicep, the client-estate Bicep and bootstrap).
deploy/helm, deploy/aks/manifests, deploy/aks/scripts, deploy/aks/values.aks.yaml not moved by this change β€” the chart is frozen in core and moves to Systemorph/MeshWeaver.Deploy in its own change The operator image already takes the chart from MeshWeaver.Deploy (see the hosting-operator section below). For the trade-offs, see Chart Ownership and the Runner Pool, "Moving the chart into a private repository".

🚨 Survivors packed in another repository must be DECLARED to the retirement sweep. scripts/orphaned-nuget-packages.py derives "orphaned" as published minus packed in this tree. After the Aspire integration left core, that derivation reads MeshWeaver.Aspire.Hosting.Memex (and MeshWeaver.MemexTemplate) as orphaned, and an apply would unlist a package we still publish. Both are therefore in PACKED_ELSEWHERE, which the self-test pins. A stale entry errs toward sparing a package, which is the safe direction.

🚨 Moving a packed project moves its publishing credential. nuget.org Trusted Publishing policies name a repository and a workflow file. The policy for core's publish-packages.yml does not cover MeshWeaver.Plugins' publish-aspire-package.yml. The package owner has to add the new policy, and set vars.NUGET_TRUSTED_USER there, before the next Aspire package release. Until then that lane's preflight is red by name.

The product demo samples

ACME, FutuRe, Northwind and PensionFund were product demos sitting in the framework's samples/Graph (with their content/ and attachments/ folders). They left as test content, not as packages: MeshWeaver.Plugins carries them at test/samples/Graph/{Data,content,attachments}, laid out exactly as here. Core keeps the minimal samples its own tests and docs use β€” Systemorph, MathDemo, PythonDemo, TestSpace, the users, Type, Doc and the infrastructure partitions.

Why test content and not a package. Every compiled consumer of these trees was already in the plugins repo β€” the suites that stage samples/Graph into their output, and the dotnet new template generator that seeds ACME β€” so that is where they went. A package would publish them, because a merge to a satellite's main lists the package in the registry. Turning FutuRe or PensionFund into a MeshWeaver.Reinsurance package is a decision for the owner. It also cannot be the vehicle for the compiled suites: MeshWeaver.Plugins does not check out MeshWeaver.Reinsurance, and pointing it at that repo would make a platform-side repo depend on a satellite. test/ is in every package enumerator's skip set, so nothing there is published.

"Plugins already has Northwind" was only half true. The plugins repo's Northwind Store package is the compiled analytics application (MeshWeaver.Northwind.Application): the dashboard, the data cube and the sales areas. Core's tree was a different demo. It had node-per-entity NodeTypes (Northwind/{Customer,Product,Supplier,Employee}) with 91 customers, 77 products and 29 suppliers as nodes, their data-bound views and in-mesh Test/ areas, the reports catalog, the articles and the documentation pages. Of all that, only the AnalyticsCatalog overlapped the package. So the tree moved whole instead of being deleted as a duplicate.

How a suite stages both trees. src/Directory.Build.props defines PluginsDemoSamplesGraph and CoreDemoSamplesExclude. A suite stages $(MeshWeaverRoot)/samples/Graph/**;$(PluginsDemoSamplesGraph)/** under one Link, and excludes the four trees from the platform's root. Each output file therefore has exactly one source, whether or not the pinned platform checkout still carries its old copy. FutuRe.Test's Compile Includes of the FutuRe sources point at the plugins copy too.

What the move surfaced. The plugins repo holds content to three ratchets that core's samples tree never met:

What CI stops compiling here. The samples stage of core's content gate used to compile these trees and run their in-mesh Test/ areas. The node-repo gate reads top-level packages only, so in the plugins repo the trees are compiled only by the suites that load them. Running core's tester over the moved trees with the same bake-then-gate.sh gives [PASS] for all four packages: 24 of 24 types compiled, the Northwind tests passed, and there were 0 warnings. A CI lane that does the same thing there is still owed.

The PensionFund idempotence intermittent entry left .github/samples-gate.allow with its subject. The defect it named is not fixed. Re-installing PensionFund rewrites Currency/{CHF,EUR,USD} without decimalPlaces, because default-suppression drops a value equal to the C# initializer. That defect is now only reachable through the moved tree.

Moving a tool whose IMAGE carries core bytes β€” the hosting operator

deploy/aks/operator (the hosting-* scripts, their behaviour suite and the Dockerfile) moved to MeshWeaver.Plugins deploy/hosting-operator, beside Hosting/InstanceAction, the plan that emits the commands. Three things made it different from a source move:

  1. The image is two repositories' bytes. It bakes core's chart, db chart and drift scripts beside the scripts, because hosting-deploy installs the chart it carries. The chart stays in core. The new lane stages core's deploy/ at the declared platform ref with the scripts overlaid where core's own build had them, so the Dockerfile's COPY lines are unchanged. A core chart change reaches :main through an hourly rebuild that runs only when the input fingerprint moved.
  2. The tag is a consumer contract. hosting-operator:main keeps its meaning. The old immutable tag (<core sha7>) was read as a core COMMIT by the estate's aks-ops lane, which checks the scripts out of core at that commit. The new tag is <plugins sha7>-<core sha7>, and <plugins sha7>-<core sha7>-<deploy sha7> once the chart came from MeshWeaver.Deploy. So core's copy may only be deleted after that lane checks the scripts out of the new home. The deleting half waits for the estate repository as well as for the adding half. The trap was the MOVING tag: every record pins :main, which the lane had always resolved in core, so supporting the new immutable tags was not enough β€” main itself had to resolve to the new homes (Memex#819).
  3. The equivalence was measured, not argued. The two images were built from the same core commit and compared. The /opt/hosting tree and /usr/local/bin were byte-identical, and the entrypoint, user and env were identical. The new lane asserts the entrypoint, the user and the command set on every run. Moving also exposed drift: core's hand-copied list of planned commands had fallen out of step with the plan. The lane in the plan's repository reads the plan instead.

The guards that covered these files moved with them: the secrets-write-only-entry allow entries, and the .gitignore re-include for bin/. Their release-channel writer is now declared across repositories ("repo" on a binding in .github/release-channels.json), which keeps hosting-operator:main protected. MeshWeaver.Plugins Hosting/HostingOperator is the manual.

Deployment material

Core's CD keeps BUILD + SEAL of the product image set (portal, migration, plugin-test, control) and publishes the sealed set into the registry; deploying it is the public MeshWeaver.Deploy repository's, and the hosting operator's image is MeshWeaver.Plugins' (the section above). The test for each item: does it produce or seal an image of the set, or prove the set is sound? (stays) β€” or does it put a sealed set somewhere, or watch where it runs? (moves). Measured on main at 16b9902f03.

There is no hand-over job: MeshWeaver.Deploy pulls. Core dispatches to no repository (policy dependents-verify-on-their-side, held by PlatformReleaseNotifyGuard.CoreDispatchesToNoRepository β€” Deploy consumes the platform's sets like any other dependent and gets no ledgered exception). Deploy's build-host.yml reads the sealed sets on its own 15-minute schedule from the registry tags promote and arm write, and turns the newest one lacking a runner into a hand-over (schema 1, MeshWeaver.Deploy's .github/scripts/platform-set-dispatch.py discover + claim). A dispatch from core was weighed and refused: it would make core name its consumer, hold a credential that writes to it, and page for its outage β€” the coupling the guard forbids. The price is one schedule tick of latency on the build host, which is outside the platform's delivery. A deploy where that latency matters (a portal roll) takes the sanctioned event route instead: core's signed build fact into the control instance's inbox, and memex's fan-out.

main-cd.yml, job by job

job class where it goes
preflight, gate build + seal stays
portal-image, portal-image-mirror, image-closure-record, migration-image, plugin-test-image build + seal stays
control-image, control-acceptance, control-promote build + seal (the control image and its acceptance) stays
promote, published-modules, compat-images, satellite-compat-image, satellite-compat, platform-ladder-compat, arm seal (the set, its evidence, the arming) stays
publish-bake, verify-images, delivery-verdict, alert-on-failure seal + its verdict stays
notify-platform-update hand-over (the release event the mesh fans out) stays: it is the platform's announcement, not a deployment
build-host-image, build-host-latest, build-host-report deploy moved to MeshWeaver.Deploy build-host.yml (image, handover, report), which reads the sealed sets itself; nothing replaces them here
control-first, control-lane-report deploy (rolls the control instance onto each accepted build) owed: not moved yet. It is the next candidate, read by Deploy from the registry (or the inbox event) the same way β€” never dispatched to it

Files

core path class where it goes
deploy/build-host/, .github/scripts/build-run.py, .github/scripts/build-host-announcement.py, .github/build-hosts.json, .github/workflows/build-host-stable.yml deploy moved to MeshWeaver.Deploy (same paths)
deploy/aks/operator/ + its image lane deploy (operator) moved to MeshWeaver.Plugins deploy/hosting-operator (hosting-operator.yml, the ONE publisher of hosting-operator:main)
deploy/helm, deploy/helm-db, deploy/aks/{manifests,scripts,values.aks.yaml}, deploy/homebrew, chart-gate.yml, homebrew.yml, test-bundle-registry.py, scripts/check-config-key-coverage.py deploy (the chart and its tooling) MeshWeaver.Deploy (seeded; later chart changes ported by a sync PR); the core delete half is a draft
deploy/aks/envs, deploy/aks/infra, deploy/aspire, memex/aspire/Memex.Aspire.Hosting deploy delete half in flight (draft)
deploy/aca, deploy/compose, deploy/compose-ha, deploy/marketplace, memex-local.yml, control-always-latest.yml, prod-synthetic-probe.yml, combo-verify.yml deploy / operations owed: no move opened yet
deploy/base-images, deploy/{node,python,pandas}-gate, deploy/{ocr,whisper,s1-extract} and their image workflows, edge-images.yml, dockerhub-mirror.yml, base-image-acr.yml build (images the product or its CI runs) stays
build-follow.yml, build-host-publication.yml, build-follow.py, build-executor.py, build-artifacts-fetch.py, compose-gate-host.sh, sandbox-postgres.sh, module-build-key.py, node-repo-scope.py, ci-artifact-store.py build (the CI side of a host run; helpers the runner image READS at the set's commit) stays
src/MeshWeaver.Deployment.Contract (BuildHostsBlock, BuildHostSetKeys, BuildHostRetention, CoreTestsDigest) contract stays: the receivers in MeshWeaver.Plugins and the records compile against it

🚨 The runner image is two repositories' bytes, like the operator's. The runner is MeshWeaver.Deploy's; its helpers and meshweaver-plugin-build are core's, fetched BY COMMIT inside the image build (PLATFORM_COMMIT = the set's own commit), never vendored, so the build context is empty. So a core change to a helper reaches the next set's runner, and a Deploy change to the runner reaches the next set too: mw-build-host:<version> is written ONCE per version, because the hosts are handed it by digest.

Package versions follow the projects: core pins only what core restores

Moving a project out of core leaves its package pins behind in Directory.Packages.props, because MeshWeaver.Plugins imports that list as its version source. Left alone, core's list keeps growing with packages nothing in core restores. Such a pin is not harmless: Dependabot and every security review read it as a core dependency, and a version decision about a Plugins host gets made in the wrong repository.

The rule. A PackageVersion lives in core only when some core project restores the package, at any depth. Anything else lives in MeshWeaver.Plugins' src/Directory.Packages.Plugins.props as a FILL-IN entry, which applies only when no other list declares the id. So the Plugins half can land first with no duplicate-pin error, and core's delete lands second.

How to measure it. A text search for PackageReference is not enough, because a pin can be needed only transitively. Restore the whole solution and read every obj/project.assets.json:

The first pass found 67 pins with no direct core reference. 12 were transitive and stayed. 48 moved to Plugins at the versions core carried, with their comments, including security overrides such as MessagePack and SQLitePCLRaw.lib.e_sqlite3.

🚨 "Nothing in core calls it" is not the test for a DIRECT reference either. A third-party assembly that core's graph carries anyway is part of the platform image's /app, and the copy there is the one every module binds: a module's own bundled copy never loads while the platform carries the name, and .NET binds only to an equal or higher version. So the version core's closure resolves is a contract with every module, even when no core line calls the package. The incident: #7262 removed MeshWeaver.Graph's Humanizer.Core 3.0.10 reference because nothing in that project called it. The image's Humanizer.dll fell back to the 2.14 that Roslyn's Workspaces brings, and every install of the Plugins module MeshWeaver.Graph.Views (built against Humanizer 3.x) was refused at boot with requires 'Humanizer 3.0.0.0 (this platform carries 2.14.0.0)'. Graph.Views contributed nothing, no course exercise page rendered, and MeshWeaver.Education's install gate went red on an unchanged commit. The Plugins fill-in pin could not help, because no project in the image's closure references the package. The lift now sits on MeshWeaver.Compiler.Pipeline, the project that brings Roslyn in, and PlatformCarriedAssemblyFloorsGuard holds the floor with the same link check the module landing uses. Before dropping a reference, also ask whether /app still carries the assembly and at which version. If the answer changes, the change is a platform downgrade for every module built against it.

Families stay whole. A package whose family core still partly pins stays in core with the rest: the four Orleans clustering and persistence packages, ModelContextProtocol and ModelContextProtocol.AspNetCore (core restores ModelContextProtocol.Core), Microsoft.Agents.AI and Microsoft.Extensions.AI. A family split across two files drifts on the first grouped version bump, and Orleans has already shipped mismatched versions once that way.

The shelf and the split families followed. The module-library shelf (tools/MeshWeaver.ModuleLibraries) was a core csproj listing libraries only Plugins modules bind. It moved to Plugins with its pins, and so did the members of the Orleans, ModelContextProtocol and Microsoft.Extensions.AI families that only Plugins restores: each takes its version from the sibling core still pins, so the family keeps one version source (Core Carries No GUI Technology, "The module-library shelf"). Octokit left once its two conditions in GitHub Sync held.

Before deleting a pin, check every satellite. A satellite may consume a pin versionless. For the 48, the only versionless consumers outside Plugins were two Reinsurance legacy/ projects. That repository's CI skips them, and they import no central list.