diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs
new file mode 100644
index 0000000..01edde1
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs
@@ -0,0 +1,116 @@
+using System.Collections.Concurrent;
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.OtOpcUa.Commons.Engines;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Scripting;
+using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
+using SerilogLogger = Serilog.ILogger;
+using SerilogLog = Serilog.Log;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
+
+///
+/// F8b — production binding. Compiles each unique
+/// expression once via (Roslyn-backed
+/// sandbox) and caches the resulting evaluator keyed by source. Subsequent evaluations are
+/// in-process method invocations on the dependency dictionary — fast enough to run inline
+/// inside the actor's message handler.
+///
+/// Single-tag mode: cross-tag ctx.SetVirtualTag writes are dropped (logged) because
+/// fan-out between actors is owned by DependencyMuxActor, not by the eval engine.
+/// Cycle detection + cascade ordering live in ; this adapter
+/// stays single-tag scoped to keep 's message loop simple.
+///
+public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IDisposable
+{
+ private static readonly SerilogLogger ScriptLogger = SerilogLog.ForContext();
+
+ private readonly ConcurrentDictionary> _cache
+ = new(StringComparer.Ordinal);
+ private readonly ILogger _logger;
+ private readonly TimeSpan _runTimeout;
+ private bool _disposed;
+
+ public RoslynVirtualTagEvaluator(ILogger logger, TimeSpan? runTimeout = null)
+ {
+ _logger = logger;
+ _runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
+ }
+
+ public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary dependencies)
+ {
+ if (_disposed) return VirtualTagEvalResult.Failure("evaluator disposed");
+ if (string.IsNullOrWhiteSpace(expression)) return VirtualTagEvalResult.Failure("empty expression");
+
+ ScriptEvaluator evaluator;
+ try
+ {
+ evaluator = _cache.GetOrAdd(expression, ScriptEvaluator.Compile);
+ }
+ catch (CompilationErrorException ex)
+ {
+ _logger.LogWarning(ex, "VirtualTag {Id}: Roslyn compile failed", virtualTagId);
+ return VirtualTagEvalResult.Failure($"compile error: {ex.Message}");
+ }
+ catch (ScriptSandboxViolationException ex)
+ {
+ _logger.LogWarning(ex, "VirtualTag {Id}: sandbox violation", virtualTagId);
+ return VirtualTagEvalResult.Failure($"sandbox violation: {ex.Message}");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "VirtualTag {Id}: compile threw", virtualTagId);
+ return VirtualTagEvalResult.Failure($"compile failure: {ex.Message}");
+ }
+
+ var readCache = BuildReadCache(dependencies);
+ var context = new VirtualTagContext(
+ readCache,
+ setVirtualTag: (path, _) =>
+ _logger.LogDebug("VirtualTag {Id}: cross-tag write to {Path} dropped (single-tag adapter)",
+ virtualTagId, path),
+ logger: ScriptLogger);
+
+ try
+ {
+ using var cts = new CancellationTokenSource(_runTimeout);
+ var raw = evaluator.RunAsync(context, cts.Token).GetAwaiter().GetResult();
+ return VirtualTagEvalResult.Ok(raw);
+ }
+ catch (OperationCanceledException)
+ {
+ return VirtualTagEvalResult.Failure($"script timed out after {_runTimeout.TotalSeconds:F1}s");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "VirtualTag {Id}: script execution threw", virtualTagId);
+ return VirtualTagEvalResult.Failure($"script threw: {ex.Message}");
+ }
+ }
+
+ private static IReadOnlyDictionary BuildReadCache(
+ IReadOnlyDictionary deps)
+ {
+ // VirtualTagContext.GetTag returns a DataValueSnapshot — we wrap each raw dep value
+ // as Good-quality so the script's `(int)ctx.GetTag("a").Value` pattern works. Null
+ // values stay null; the script can null-check via GetTag(path).Value.
+ var nowUtc = DateTime.UtcNow;
+ var cache = new Dictionary(StringComparer.Ordinal);
+ foreach (var kv in deps)
+ {
+ cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc);
+ }
+ return cache;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ foreach (var ev in _cache.Values)
+ {
+ try { ev.Dispose(); } catch { /* best-effort */ }
+ }
+ _cache.Clear();
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
index 3043d0a..57d5145 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
@@ -8,8 +8,10 @@ using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.ControlPlane;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Host;
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
+using ZB.MOM.WW.OtOpcUa.Host.Engines;
using ZB.MOM.WW.OtOpcUa.Host.Health;
using ZB.MOM.WW.OtOpcUa.Host.Observability;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
@@ -70,6 +72,13 @@ if (hasDriver)
// ILdapAuthService is registered by AddOtOpcUaAuth on admin nodes; on driver-only nodes
// it isn't, so we register the LDAP options + service unconditionally for driver hosts
// to keep parity. The LdapAdapter falls back to Deny on any backend error.
+ // F8b — production virtual-tag evaluator (Roslyn-compiled scripts cached per expression).
+ // Replaces the F8-default NullVirtualTagEvaluator so VirtualTagActor evaluates real user
+ // scripts at runtime.
+ builder.Services.AddSingleton(sp =>
+ new RoslynVirtualTagEvaluator(sp.GetRequiredService().CreateLogger()));
+ builder.Services.AddSingleton(sp => sp.GetRequiredService());
+
builder.Services.AddOptions().Bind(builder.Configuration.GetSection("Ldap"));
builder.Services.AddSingleton();
builder.Services.AddSingleton();
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj
index be53447..db80245 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj
@@ -5,6 +5,12 @@
OtOpcUa.Host
zb-mom-ww-otopcua-host
true
+
+ $(NoWarn);NU1608
@@ -24,6 +30,8 @@
+
+
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs
new file mode 100644
index 0000000..634795a
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs
@@ -0,0 +1,92 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Host.Engines;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
+
+///
+/// F8b — verifies compiles user expressions through
+/// the Core.Scripting sandbox, runs them against the dependency dictionary, caches the
+/// compiled assembly per source, and surfaces failures (compile error, sandbox violation,
+/// runtime throw) as VirtualTagEvalResult.Failure instead of propagating exceptions.
+///
+public sealed class RoslynVirtualTagEvaluatorTests
+{
+ [Fact]
+ public void Evaluate_simple_addition_returns_summed_value()
+ {
+ using var sut = new RoslynVirtualTagEvaluator(NullLogger.Instance);
+
+ var result = sut.Evaluate(
+ virtualTagId: "vt-sum",
+ expression: "return (int)ctx.GetTag(\"a\").Value + (int)ctx.GetTag(\"b\").Value;",
+ dependencies: new Dictionary { ["a"] = 10, ["b"] = 32 });
+
+ result.Success.ShouldBeTrue(result.Reason);
+ result.Value.ShouldBe(42);
+ }
+
+ [Fact]
+ public void Evaluate_caches_compiled_expression_across_calls()
+ {
+ using var sut = new RoslynVirtualTagEvaluator(NullLogger.Instance);
+ const string expr = "return (int)ctx.GetTag(\"x\").Value * 2;";
+
+ var first = sut.Evaluate("vt-cache", expr, new Dictionary { ["x"] = 5 });
+ var second = sut.Evaluate("vt-cache", expr, new Dictionary { ["x"] = 7 });
+
+ first.Success.ShouldBeTrue(first.Reason);
+ first.Value.ShouldBe(10);
+ second.Success.ShouldBeTrue(second.Reason);
+ second.Value.ShouldBe(14);
+ }
+
+ [Fact]
+ public void Evaluate_compile_error_returns_Failure_with_reason()
+ {
+ using var sut = new RoslynVirtualTagEvaluator(NullLogger.Instance);
+
+ var result = sut.Evaluate("vt-bad", "this is not valid C#;", new Dictionary());
+
+ result.Success.ShouldBeFalse();
+ result.Reason.ShouldNotBeNull();
+ result.Reason.ShouldContain("compile");
+ }
+
+ [Fact]
+ public void Evaluate_runtime_exception_returns_Failure_with_reason()
+ {
+ using var sut = new RoslynVirtualTagEvaluator(NullLogger.Instance);
+
+ var result = sut.Evaluate(
+ virtualTagId: "vt-div0",
+ expression: "int a = 0; return 1 / a;",
+ dependencies: new Dictionary());
+
+ result.Success.ShouldBeFalse();
+ result.Reason.ShouldNotBeNull();
+ result.Reason.ShouldContain("threw");
+ }
+
+ [Fact]
+ public void Evaluate_empty_expression_returns_Failure()
+ {
+ using var sut = new RoslynVirtualTagEvaluator(NullLogger.Instance);
+
+ sut.Evaluate("vt-empty", "", new Dictionary()).Success.ShouldBeFalse();
+ sut.Evaluate("vt-empty", " ", new Dictionary()).Success.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Evaluate_after_dispose_returns_Failure()
+ {
+ var sut = new RoslynVirtualTagEvaluator(NullLogger.Instance);
+ sut.Dispose();
+
+ var result = sut.Evaluate("vt", "return 1;", new Dictionary());
+
+ result.Success.ShouldBeFalse();
+ result.Reason!.ShouldContain("disposed");
+ }
+}