Repository navigation
A moved type is a binary break: the MCP module could not construct its plugin (#2370) - #2397
Conversation
…s plugin (#2370) Every MCP tool call from every external client of the deployment died in the `McpMeshPlugin` constructor: System.TypeLoadException: Could not load type 'MeshWeaver.AI.MeshOperations' from assembly 'MeshWeaver.AI, Version=3.0.0.0, Culture=neutral, PublicKeyToken=null' Not `search` — ALL of them. The MCP SDK builds its tool target per invocation (`McpServerBuilderExtensions.CreateTarget` -> `ActivatorUtilities.CreateInstance`), so the failure sits in a constructor every single call has to run: `get`, `create`, `update`, `render_area`, the LSP and chunk tools alike. A full outage of the `/mcp` surface. **Root cause.** #2283 moved `MeshOperations`, `MeshExportManifest`, `MeshExportFileEntry` and `NodeReadOutcome` out of `MeshWeaver.AI` into the new `MeshWeaver.Mesh.Operations`, renaming their namespace to `MeshWeaver.Mesh` on the way. A module's IL holds neither a `using` nor a source reference — it holds `TypeRef MeshWeaver.AI.MeshOperations` scoped to the `MeshWeaver.AI` AssemblyRef — so the store-installed `MeshWeaver.Mcp` 1.1.4, built before the move, bound a name that no longer existed. The module lane's gate is a semver floor and deliberately never MVID equality, precisely so that "a landed module keeps loading across ordinary platform updates" (Modules.md). That is a promise about BINARY compatibility, and this broke it. **The verification that passed was the wrong one.** #2283 was checked by BUILDING the plugins repo's `MeshWeaver.Mcp` against the branch — 0 errors. That answers "does the module's SOURCE still compile", which is a different question from "does the module ALREADY PUBLISHED still bind"; the module's source carries `using` directives for both namespaces. `landed-modules-gate` has the same blind spot by construction and went green on the change that caused the outage. **Fix: keep the move, restore the names, forward the types.** The assembly move stands. The four types keep their ORIGINAL full names (`namespace MeshWeaver.AI`, inside `MeshWeaver.Mesh.Operations`) and `MeshWeaver.AI` carries `[assembly: TypeForwardedTo]` for each, so the CLR resolves the old 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 AGENTS.md forbids. A forwarder cannot rename, which is why the namespace is now marked frozen where it is declared, and why `NodeReadOutcome` gets its original name back (`NodeReadResult` was a rename to dodge a collision that exists only in `MeshWeaver.Mesh`). **And the class, not just the instance.** `scripts/check-type-forwards.py` refuses any public type moving between `src/` assemblies without a forwarder for its original full name. It carries 17 self-tests (wired as its own step before the gate, so a broken matcher fails loudly), and it is verified in situ against real history: replaying the breaking merge — `--base b53aed0^1 --head b53aed0` — it names all four types and exits 1, while across the last 25 merges on main it reports zero. It runs beside #2298's `check-record-signatures.py`, which covers the primary-constructor half of the same class; the job is renamed *Public surface (binary compatibility)* to cover both. An assembly that leaves the repo entirely is deliberately out of scope, with a self-test pinning that (without it the Blazor exit alone produces 15 false positives). `MovedTypeBinaryContractTest` proves the forwarders resolve the way a module's TypeRef does: with `TypeForwards.cs` removed, 5 of its 6 assertions fail; the sixth pins the names from the other side. Verified: full solution build under CI's flags (`-c Release -p:CIRun=true -warnaserror`) — 0 warnings, 0 errors — and the plugins repo's `src/MeshWeaver.Mcp` at its own `main` built against this branch with `-p:MeshWeaverRoot=…`, also clean, which is landed-modules-gate parity for the module that broke. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Follow-up for the remaining exposure filed as #2398 — 13 more unguarded moves since |
…ch tip A diff gate that anchors on `origin/main` re-reads every change that lands there while a PR is open. For this one that is not cosmetic: a public type moving on main shows up, against the tip, as THIS PR moving it BACK — a red on somebody else's change, on the gate whose whole value is that people believe it. Four such moves landed on main inside one day (#2398), so the window is not hypothetical. `actions/checkout` builds a merge commit for a pull_request, so `git merge-base origin/<base> HEAD` resolves to the base tip that commit was formed from — which keeps the comparison exactly this PR's own diff even after main moves on. `check-record-signatures.py` (#2298) has the same latent anchoring in its step; left alone here and noted on #2398 rather than widened into this fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… forwarder `parse_forwards` reads the text inside `typeof(...)`, so it only recognised the fully-qualified spelling. A forwarder written `[assembly: TypeForwardedTo(typeof(MeshOperations))]`, resolved through the file's usings, is the identical metadata — and the gate would have demanded a forwarder that was already there, which is the fastest way to teach someone to allow-list past it. Matched within the leaving assembly only, so it cannot be satisfied by a same-named type's forwarder somewhere else. 18th self-test pins it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
⏳ Not mergeable yet, and not because of this branch: GitHub Actions is in a
The symptom on this PR, so it is not mistaken for a branch problem later:
Nothing to re-run and nothing to fix here: run creation is what is failing. Held at the merge Verified locally in the meantime: full solution build under CI's flags (0 warnings, 0 errors), the |
There was a problem hiding this comment.
Pull request overview
Restores binary compatibility for store-installed modules (notably MeshWeaver.Mcp) after public type moves by re-exposing moved types under their original MeshWeaver.AI full names via type forwarders, and adds CI/test/documentation guards to prevent reintroducing this class of break.
Changes:
- Re-freezes moved public types under
namespace MeshWeaver.AIand addsMeshWeaver.AItype forwarders so older modules’TypeRefbindings keep resolving after platform updates. - Adds a binary-contract test (
MovedTypeBinaryContractTest) plus updates existing tests to pin the “frozen” namespace expectation. - Introduces a new CI gate (
scripts/check-type-forwards.py+ workflow wiring + allowlist file) and documents the binary-compatibility contract in Modules and What’s New.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tools/MeshWeaver.PluginTester/GateAllowlist.cs | Clarifies record-signature gate naming/reference in docs comment. |
| test/MeshWeaver.Hosting.Monolith.Test/NodeTypeCompileParkTest.cs | Pins MeshWeaver.AI namespace usage for MeshOperations in tests. |
| test/MeshWeaver.Hosting.Monolith.Test/CodeEditRecompileTest.cs | Pins MeshWeaver.AI namespace usage for MeshOperations in tests. |
| test/MeshWeaver.Autocomplete.Test/AutocompleteMultiSourceTest.cs | Pins MeshWeaver.AI namespace usage for MeshOperations in tests. |
| test/MeshWeaver.AI.Test/NodeReadOutcomeClassifierTest.cs | Updates test to the restored NodeReadOutcome name. |
| test/MeshWeaver.AI.Test/MovedTypeBinaryContractTest.cs | New test asserting old module-style type resolution still works and forwarders are real forwarders. |
| src/MeshWeaver.Mesh.Operations/README.md | Documents the “frozen namespace” binary contract and the #2370 failure mode. |
| src/MeshWeaver.Mesh.Operations/NodeReadOutcome.cs | Restores MeshWeaver.AI namespace + NodeReadOutcome name for binary compatibility. |
| src/MeshWeaver.Mesh.Operations/MeshOperations.cs | Restores MeshWeaver.AI namespace and documents why it must not change. |
| src/MeshWeaver.Mesh.Operations/MeshExportManifest.cs | Restores MeshWeaver.AI namespace for export types’ binary identity. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-26-mcp-tools-work-again-after-a-platform-update.md | New release note explaining the outage and the fix. |
| src/MeshWeaver.Documentation/Data/Architecture/Modules.md | Documents the platform’s binary-compatibility obligation for module loading. |
| src/MeshWeaver.AI/TypeForwards.cs | Adds [assembly: TypeForwardedTo] entries for the moved types. |
| scripts/type-forwards.allow | Adds allowlist mechanism for intentional, safe moves (ratcheted). |
| scripts/check-type-forwards.py | New gate to refuse public type moves without forwarders. |
| memex/Memex.LocalMesh/LocalMeshApiEndpoints.cs | Pins MeshWeaver.AI namespace usage for MeshOperations in memex local mesh endpoints. |
| .github/workflows/dotnet-test.yml | Renames the job and wires in the new type-move binary-compatibility gate steps. |
Suppressed comments (1)
scripts/check-type-forwards.py:145
- After allowing indentation in
TYPE_RE,parse_declarationsshould filter out nested types (and avoid treating 4-space-indented nested types in file-scoped-namespace files as top-level declarations). Without this, the gate risks both false negatives (still missing block-namespace top-level types) and false positives (nested types inside file-scoped namespaces).
def parse_declarations(path: str, text: str) -> list[Decl]:
"""Public top-level types declared by one file, with the namespace in force."""
assembly = _assembly_of(path)
namespaces = [(m.start(), m.group("ns")) for m in NAMESPACE_RE.finditer(text)]
out: list[Decl] = []
for m in TYPE_RE.finditer(text):
ns = ""
for start, candidate in namespaces:
if start < m.start():
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…aimed to cover (review) Copilot's review caught a real hole: `TYPE_RE` was anchored at column 0, and 24 files under `src/` still declare a BLOCK-scoped namespace, which indents every top-level type by four (`MeshWeaver.Data/TypeDescription.cs`). Those 31 public types — `TypeDescription`, `SchemaInfo`, `SynchronizationAddress`, `IMessageHubGrain`, `PartitionedHubDataSource`, … — were invisible to the gate, which would have reported green while covering less than it said. That is the same defect class the gate exists to catch, one level up. The indent is now CAPTURED instead of forbidden, and nesting is excluded by comparing it against the enclosing namespace's FORM: a file-scoped `namespace X;` puts top-level types at column 0, a block-scoped one at column 4 (brace on the same line or the next), and anything deeper is a nested type, which no module binds by its own simple name. Three self-tests pin it, including the nested-inside-a-block-namespace case that a naive "allow any indent" fix would wrongly pick up — 20 in total. Unchanged where it matters: the in-situ replay of the breaking merge still names exactly the four types, and the sweep over the last 25 merges on main still reports zero. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Test Results (shard 3) 9 files 9 suites 3m 58s ⏱️ Results for commit 4e0d50a. |
Test Results (shard 0)599 tests 595 ✅ 15m 47s ⏱️ Results for commit 4e0d50a. |
Test Results (shard 5) 9 files 9 suites 7m 21s ⏱️ Results for commit 4e0d50a. |
Test Results (shard 2)2 878 tests 2 775 ✅ 8m 21s ⏱️ Results for commit 4e0d50a. |
Test Results (shard 1)2 027 tests 1 834 ✅ 9m 28s ⏱️ Results for commit 4e0d50a. |
Test Results (shard 4)2 194 tests 2 192 ✅ 15m 4s ⏱️ Results for commit 4e0d50a. |
Test Results 53 files 53 suites 1h 0m 1s ⏱️ Results for commit 4e0d50a. |
Closes #2370.
The failure
Every MCP tool call from every external client of the deployment died in the
McpMeshPluginconstructor:
Not
search— all of them. The MCP SDK builds its tool target per invocation(
CreateTarget→ActivatorUtilities.CreateInstance), so the failure sits in a constructor everycall has to run:
get,create,update,render_area, the LSP and chunk tools alike.Root cause, from evidence
b53aed0aa, merged 2026-08-26 00:56Z) tookMeshOperations,MeshExportManifest,MeshExportFileEntry,NodeReadOutcomeout ofMeshWeaver.AIinto the newMeshWeaver.Mesh.Operations, renaming their namespace toMeshWeaver.Meshusing—TypeRef MeshWeaver.AI.MeshOperationsscoped to theMeshWeaver.AIAssemblyRefMeshWeaver.Mcp1.1.4, built before the moveThe module lane's gate is a semver floor, never MVID equality, precisely so that "a landed
module keeps loading across ordinary platform updates" (
Doc/Architecture/Modules→ the skiprules). That sentence is a promise about binary compatibility. Moving a public type to another
assembly, or renaming its namespace, breaks it for every module compiled earlier.
The verification that passed was the wrong one. #2283 was checked by building the plugins
repo's
MeshWeaver.Mcpagainst the branch — 0 errors. That answers "does the module's SOURCE stillcompile", not "does the module ALREADY PUBLISHED still bind"; the module's source imports both
namespaces.
landed-modules-gatehas the identical blind spot by construction and went green on thechange that caused the outage.
check-record-signatures.py(#2298) covers the constructor half ofthis class and stops at constructors. The semver floor cannot see a type at all.
The fix
Keep the move, restore the names, forward the types.
namespace MeshWeaver.AI, insideMeshWeaver.Mesh.Operations— andsrc/MeshWeaver.AI/TypeForwards.cscarries[assembly: TypeForwardedTo]for each. The CLR resolves the module's old TypeRef throughMeshWeaver.AIto one type identity, not a shim (a shim would mint a second identity andreintroduce the
as/istrap-door AGENTS.md forbids).NodeReadOutcomegets its original name back (NodeReadResultwas a rename chosen to dodge acollision that exists only in
MeshWeaver.Mesh).…and the class, not just the instance
scripts/check-type-forwards.pyrefuses any public type moving betweensrc/assemblies without aforwarder for its original full name.
rather than passing everything.
exits 1:
pinning it — without that rule the Blazor exit alone produces 15 false positives, and a gate that
cries wolf teaches people to allow-list.
same ratchet as the repo's others.
It runs beside #2298's gate in the same job, renamed Public surface (binary compatibility).
MovedTypeBinaryContractTestproves the forwarders resolve the way a module's TypeRef does — withTypeForwards.csremoved, 5 of its 6 assertions fail; the sixth pins the names from the otherside (compile-time
typeof).Verification
-c Release -p:CIRun=true -warnaserror) — 0 warnings, 0 errors.src/MeshWeaver.Mcpat its ownmain, built against this branch with-p:MeshWeaverRoot=…— 0 warnings, 0 errors. That islanded-modules-gateparity for themodule that broke, so no MeshWeaver.Plugins change is required: with the forwarders, a bundle
built before or after MeshOperations is not AI — it leaves the AI assembly (#2276 step A1) #2283 loads.
MovedTypeBinaryContractTest+NodeReadOutcomeClassifierTest: 16 passed.WhatsNewEntryIntegrityTest,DocumentationLinkIntegrityTest,MigrationWorkloadModelGuard: 5 passed.Not fixed here — reported separately
Running the new gate over
v3.0.0-rc7 → mainfinds 17 unguarded public-type moves; this PRcovers four. Three of the remaining thirteen have a proven module consumer in the plugins repo
(
IMcpBackConnection,McpConnectionInfo,IProviderKeyProtector) and are already being worked onin dedicated branches. Two groups (
MeshWeaver.GitSync→MeshWeaver.AI,MeshWeaver.Hosting→MeshWeaver.AI) cannot be forwarded at all — the forwarder must live in the assembly being left,which would need a reference cycle — so those require either moving the type back or a genuinely
atomic republish (#2234). Details in the follow-up issue.