diff --git a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/CachingScriptMetadataResolver.cs b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/CachingScriptMetadataResolver.cs
new file mode 100644
index 00000000..0ee74998
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/CachingScriptMetadataResolver.cs
@@ -0,0 +1,91 @@
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Scripting;
+
+namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
+
+///
+/// Process-wide memoizing decorator over Roslyn's default script metadata
+/// resolver, closing the second (dominant) half of the script-compile native
+/// memory leak fixed in part by 5a781c70.
+///
+///
+/// Why this exists. The shared ScriptOptions on every compile
+/// surface carry only the direct API-surface references; each
+/// script.Compile() binds their transitive closure, and every
+/// transitively-referenced assembly is resolved through the options'
+/// . Roslyn's
+/// RuntimeMetadataReferenceResolver has NO cross-compilation cache: each
+/// resolution calls MetadataReference.CreateFromFile, which eagerly
+/// copies the whole assembly into native memory (AssemblyMetadata →
+/// PEReader → NativeHeapMemoryBlock). Measured on Roslyn 5.0.0:
+/// a 5-assembly explicit set resolves a 74-assembly closure afresh on EVERY
+/// compile — ~74 fresh native metadata copies per compiled script, pinned for
+/// the process lifetime by the compile caches. This decorator memoizes, so each
+/// distinct assembly is materialized once per process regardless of compile count.
+///
+///
+///
+/// Trust model unaffected. Resolution RESULTS are identical to the inner
+/// resolver's — only object identity is de-duplicated. The resolver can never
+/// resolve anything the undecorated options would not have resolved.
+///
+///
+///
+/// Cache-correctness assumptions. The missing-assembly cache keys on the
+/// assembly identity display name and deliberately ignores the requesting
+/// definition (whose directory is a search path in the inner resolver):
+/// every ScadaBridge node runs from a single publish directory plus the shared
+/// framework, so identity → path is stable process-wide. A
+/// GetOrAdd factory race can mint one duplicate — bounded, benign.
+/// Entries are never disposed; the cache is bounded by the distinct assemblies
+/// on disk, the same order as the static .
+///
+///
+public sealed class CachingScriptMetadataResolver : MetadataReferenceResolver
+{
+ ///
+ /// The shared process-wide instance every script-compile surface attaches via
+ /// ScriptOptions.WithMetadataResolver. Decorates
+ /// 's resolver — the exact resolver those
+ /// surfaces used implicitly before this fix.
+ ///
+ public static readonly CachingScriptMetadataResolver Instance =
+ new(ScriptOptions.Default.MetadataResolver);
+
+ private readonly MetadataReferenceResolver _inner;
+
+ private readonly ConcurrentDictionary _missingByIdentity =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ private readonly ConcurrentDictionary<(string Reference, string? BaseFilePath, MetadataReferenceProperties Properties),
+ ImmutableArray> _referencesByPath = new();
+
+ /// Creates a decorator over the given inner resolver. Exposed for tests; production uses .
+ /// The resolver whose results are memoized.
+ public CachingScriptMetadataResolver(MetadataReferenceResolver inner) => _inner = inner;
+
+ ///
+ public override bool ResolveMissingAssemblies => _inner.ResolveMissingAssemblies;
+
+ ///
+ public override PortableExecutableReference? ResolveMissingAssembly(
+ MetadataReference definition, AssemblyIdentity referenceIdentity)
+ => _missingByIdentity.GetOrAdd(
+ referenceIdentity.GetDisplayName(),
+ _ => _inner.ResolveMissingAssembly(definition, referenceIdentity));
+
+ ///
+ public override ImmutableArray ResolveReference(
+ string reference, string? baseFilePath, MetadataReferenceProperties properties)
+ => _referencesByPath.GetOrAdd(
+ (reference, baseFilePath, properties),
+ _ => _inner.ResolveReference(reference, baseFilePath, properties));
+
+ ///
+ public override bool Equals(object? other) => ReferenceEquals(this, other);
+
+ ///
+ public override int GetHashCode() => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(this);
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/CachingScriptMetadataResolverTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/CachingScriptMetadataResolverTests.cs
new file mode 100644
index 00000000..b35b1c8d
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/CachingScriptMetadataResolverTests.cs
@@ -0,0 +1,92 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Scripting;
+
+namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests;
+
+///
+/// Pins the per-compile native-metadata fix (2026-08-12 follow-up to 5a781c70):
+/// Roslyn's default ScriptMetadataResolver re-resolves the transitive assembly
+/// closure through MetadataReference.CreateFromFile on EVERY script compile —
+/// each call minting a fresh AssemblyMetadata → PEReader → NativeHeapMemoryBlock.
+/// The decorator memoizes so each distinct assembly is resolved once per process.
+///
+public class CachingScriptMetadataResolverTests
+{
+ private sealed class CountingResolver : MetadataReferenceResolver
+ {
+ public int MissingCalls;
+ public int ReferenceCalls;
+ public override bool ResolveMissingAssemblies => true;
+
+ public override PortableExecutableReference? ResolveMissingAssembly(
+ MetadataReference definition, AssemblyIdentity referenceIdentity)
+ {
+ Interlocked.Increment(ref MissingCalls);
+ return MetadataReference.CreateFromFile(typeof(object).Assembly.Location);
+ }
+
+ public override ImmutableArray ResolveReference(
+ string reference, string? baseFilePath, MetadataReferenceProperties properties)
+ {
+ Interlocked.Increment(ref ReferenceCalls);
+ return [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)];
+ }
+
+ public override bool Equals(object? other) => ReferenceEquals(this, other);
+ public override int GetHashCode() => 0;
+ }
+
+ private static readonly AssemblyIdentity SomeIdentity = new("System.Fake", new Version(1, 0, 0, 0));
+ private static readonly MetadataReference SomeDefinition =
+ MetadataReference.CreateFromFile(typeof(object).Assembly.Location);
+
+ [Fact]
+ public void ResolveMissingAssembly_SameIdentityTwice_ResolvesOnceAndSharesInstance()
+ {
+ var inner = new CountingResolver();
+ var sut = new CachingScriptMetadataResolver(inner);
+
+ var first = sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);
+ var second = sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);
+
+ Assert.Equal(1, inner.MissingCalls);
+ Assert.Same(first, second); // one AssemblyMetadata/PEReader/native block, not two
+ }
+
+ [Fact]
+ public void ResolveMissingAssembly_DistinctIdentities_ResolveIndependently()
+ {
+ var inner = new CountingResolver();
+ var sut = new CachingScriptMetadataResolver(inner);
+
+ sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);
+ sut.ResolveMissingAssembly(SomeDefinition, new AssemblyIdentity("System.Other", new Version(1, 0, 0, 0)));
+
+ Assert.Equal(2, inner.MissingCalls);
+ }
+
+ [Fact]
+ public void ResolveReference_SameArgsTwice_ResolvesOnceAndSharesInstances()
+ {
+ var inner = new CountingResolver();
+ var sut = new CachingScriptMetadataResolver(inner);
+
+ var first = sut.ResolveReference("System.Xml", baseFilePath: null, MetadataReferenceProperties.Assembly);
+ var second = sut.ResolveReference("System.Xml", baseFilePath: null, MetadataReferenceProperties.Assembly);
+
+ Assert.Equal(1, inner.ReferenceCalls);
+ Assert.Same(first[0], second[0]);
+ }
+
+ [Fact]
+ public void Instance_IsProcessWideSingleton()
+ => Assert.Same(CachingScriptMetadataResolver.Instance, CachingScriptMetadataResolver.Instance);
+
+ [Fact]
+ public void ResolveMissingAssemblies_DelegatesToInner()
+ {
+ var sut = new CachingScriptMetadataResolver(new CountingResolver());
+ Assert.True(sut.ResolveMissingAssemblies);
+ }
+}