Runbook — Plugin MissingMethodException (cross-ALC type identity)

Applies to: any plugin loaded into a PluginLoadContext (Concelier/Excititor connectors are the usual trigger; the class is general). Companion: docs/plugins/ARCHITECTURE.md (the ALC mechanism), the concelier-connector role playbook (AGENTS.md — Memory) (field summary). Verify against src/ — code wins.

Symptom

A signed/working plugin throws, at map/parse time, something like:

System.MissingMethodException: Method not found:
  '...TryParseSemVer(System.String, NuGet.Versioning.SemanticVersion ByRef, System.String ByRef)'

…even though a reflection probe shows every copy of the assembly (host /app and every plugin bundle) physically contains that method. It looks like a stale/build-skew DLL. It is not.

Root cause — second ALC type identity

The plugin bundle ships its own copy of a host-shared contract assembly (byte-identical is still wrong). The plugin loads into a plugin ALC; the shared dep resolves inconsistently, so the type at the call site and the type in the resolved method signature come from two different ALC-loaded copies → two distinct System.Type → the CLR cannot bind the method. Only the plugin whose call path crosses a doubly-loaded type trips it (e.g. a mapper calling a method whose signature carries NuGet.Versioning.SemanticVersion); siblings that don’t hit that signature map fine.

Known doubly-loaded contract surfaces:

Loader note: PluginLoadContext (StellaOps.Plugin.Hosting) does prefer host assemblies — but only those already loaded in the default ALC. A lazy-load ordering race lets the bundle copy load first.

Fix — prune host-present assemblies from the bundle

Determine which assemblies the host actually provides, then prune duplicate shared contracts before generating the bundle’s manifest, checksums and signatures. Retain the plugin’s own assembly and its private dependencies. Do not assume removing a file leaves an existing bundle signature valid; verification depends on what that bundle’s manifest/signature covers.

The current packaging socket is Remove-HostSharedPluginAssemblies in devops/build/package-runtime-plugins.ps1. Its $sharedPrefixes and $sharedExact lists describe the mounted host contract and it logs [host-shared-prune]. Its callers invoke it before signing. Verify a host and bundle’s actual closure before extending those lists.

The former Concelier-specific $contractSurfaceThirdParty branch and its producers were retired with VULN-B1 on 2026-09-11. The old tools/scripts/deploy/prune-concelier-bundle-shared-assemblies.sh describes that historical layout; it is not an operator procedure for the current hub.

Two variants that look the same but need a different fix

  1. Host /app/*.Models.dll newer than the plugins (connector plugins ship no own Models.dll): Advisory..ctor in a mapper throws MissingMethodException, which is swallowed — the job reports “succeeded”, 0 edges change, the doc → failed. Fix = a coherent image REBUILD (not a recreate) so host and plugins are built from the same tree. Operator-scope; see the live-deploy-operator role playbook (AGENTS.md — Memory).
  2. Plugin transitive StellaOps.* deps fail GetTypes()because the default ALC only probes the TPA list and the host doesn’t carry them. Fix = a Resolving hook on the load context (not a prune).

Dev signature gotcha

The dev required-bundle gate enforces .sig presence even when EnforceSignatureVerification=false. After pruning/repackaging, sign with -SignConcelierBundles -UseOfflineDevSigner or the bundle is rejected.

Verify the fix (live forcing-function)

Re-run the map and confirm real movement: docs go failed → mapped, source_states/edges change, and no MissingMethodException in logs. A “job succeeded” with 0 changes is not proof — that’s exactly the swallowed-failure signature.