diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs
new file mode 100644
index 0000000..a0e9a1d
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs
@@ -0,0 +1,107 @@
+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.ScriptedAlarms;
+using ZB.MOM.WW.OtOpcUa.Core.Scripting;
+using SerilogLogger = Serilog.ILogger;
+using SerilogLog = Serilog.Log;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
+
+///
+/// F9b — production binding. Compiles each unique
+/// predicate once via against
+/// and caches the resulting evaluator. Predicates are
+/// pure functions returning bool:
+/// throws so a misbehaving script can't smuggle a side effect into alarm evaluation.
+///
+/// Failure modes (compile error, sandbox violation, runtime exception, timeout) all surface
+/// as ;
+/// preserves the prior state on failure (does not flip Active/Inactive).
+///
+public sealed class RoslynScriptedAlarmEvaluator : IScriptedAlarmEvaluator, 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 RoslynScriptedAlarmEvaluator(ILogger logger, TimeSpan? runTimeout = null)
+ {
+ _logger = logger;
+ _runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
+ }
+
+ public ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary dependencies)
+ {
+ if (_disposed) return ScriptedAlarmEvalResult.Failure("evaluator disposed");
+ if (string.IsNullOrWhiteSpace(predicate)) return ScriptedAlarmEvalResult.Failure("empty predicate");
+
+ ScriptEvaluator evaluator;
+ try
+ {
+ evaluator = _cache.GetOrAdd(predicate, ScriptEvaluator.Compile);
+ }
+ catch (CompilationErrorException ex)
+ {
+ _logger.LogWarning(ex, "Alarm {Id}: predicate compile failed", alarmId);
+ return ScriptedAlarmEvalResult.Failure($"compile error: {ex.Message}");
+ }
+ catch (ScriptSandboxViolationException ex)
+ {
+ _logger.LogWarning(ex, "Alarm {Id}: predicate sandbox violation", alarmId);
+ return ScriptedAlarmEvalResult.Failure($"sandbox violation: {ex.Message}");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Alarm {Id}: predicate compile threw", alarmId);
+ return ScriptedAlarmEvalResult.Failure($"compile failure: {ex.Message}");
+ }
+
+ var readCache = BuildReadCache(dependencies);
+ var context = new AlarmPredicateContext(readCache, ScriptLogger);
+
+ try
+ {
+ using var cts = new CancellationTokenSource(_runTimeout);
+ var active = evaluator.RunAsync(context, cts.Token).GetAwaiter().GetResult();
+ return ScriptedAlarmEvalResult.Ok(active);
+ }
+ catch (OperationCanceledException)
+ {
+ return ScriptedAlarmEvalResult.Failure($"predicate timed out after {_runTimeout.TotalSeconds:F1}s");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Alarm {Id}: predicate execution threw", alarmId);
+ return ScriptedAlarmEvalResult.Failure($"predicate threw: {ex.Message}");
+ }
+ }
+
+ private static IReadOnlyDictionary BuildReadCache(
+ IReadOnlyDictionary deps)
+ {
+ 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 57d5145..ded12f3 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
@@ -79,6 +79,12 @@ if (hasDriver)
new RoslynVirtualTagEvaluator(sp.GetRequiredService().CreateLogger()));
builder.Services.AddSingleton(sp => sp.GetRequiredService());
+ // F9b — same pattern for scripted-alarm predicates. The actor preserves prior state on
+ // any Failure result, so a misbehaving script can't flip Active/Inactive spuriously.
+ builder.Services.AddSingleton(sp =>
+ new RoslynScriptedAlarmEvaluator(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 db80245..3b7e33b 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
@@ -32,6 +32,7 @@
+
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynScriptedAlarmEvaluatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynScriptedAlarmEvaluatorTests.cs
new file mode 100644
index 0000000..5e7a0e4
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynScriptedAlarmEvaluatorTests.cs
@@ -0,0 +1,102 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Host.Engines;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
+
+///
+/// F9b — verifies compiles alarm predicates,
+/// returns the bool result on success, surfaces compile/runtime errors as Failure (so the
+/// actor preserves prior state), and rejects predicates that try to ctx.SetVirtualTag (the
+/// AlarmPredicateContext throws on writes — predicates must stay pure).
+///
+public sealed class RoslynScriptedAlarmEvaluatorTests
+{
+ [Fact]
+ public void Evaluate_predicate_returning_true_reports_Active()
+ {
+ using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance);
+
+ var result = sut.Evaluate(
+ alarmId: "alarm-hi",
+ predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;",
+ dependencies: new Dictionary { ["temp"] = 150 });
+
+ result.Success.ShouldBeTrue(result.Reason);
+ result.Active.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Evaluate_predicate_returning_false_reports_Inactive()
+ {
+ using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance);
+
+ var result = sut.Evaluate(
+ alarmId: "alarm-hi",
+ predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;",
+ dependencies: new Dictionary { ["temp"] = 50 });
+
+ result.Success.ShouldBeTrue(result.Reason);
+ result.Active.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Evaluate_caches_compiled_predicate_across_calls()
+ {
+ using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance);
+ const string predicate = "return (bool)ctx.GetTag(\"door_open\").Value;";
+
+ var first = sut.Evaluate("alarm-door", predicate, new Dictionary { ["door_open"] = true });
+ var second = sut.Evaluate("alarm-door", predicate, new Dictionary { ["door_open"] = false });
+
+ first.Active.ShouldBeTrue();
+ second.Active.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Evaluate_compile_error_returns_Failure()
+ {
+ using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance);
+
+ var result = sut.Evaluate("alarm-bad", "this isn't C#;", new Dictionary());
+
+ result.Success.ShouldBeFalse();
+ result.Reason!.ShouldContain("compile");
+ }
+
+ [Fact]
+ public void Evaluate_predicate_writing_virtual_tag_returns_Failure()
+ {
+ using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance);
+
+ // AlarmPredicateContext.SetVirtualTag throws — wrapper catches + reports as Failure.
+ var result = sut.Evaluate(
+ alarmId: "alarm-bad-write",
+ predicate: "ctx.SetVirtualTag(\"x\", 1); return true;",
+ dependencies: new Dictionary());
+
+ result.Success.ShouldBeFalse();
+ result.Reason!.ShouldContain("threw");
+ }
+
+ [Fact]
+ public void Evaluate_empty_predicate_returns_Failure()
+ {
+ using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance);
+
+ sut.Evaluate("alarm-empty", "", new Dictionary()).Success.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Evaluate_after_dispose_returns_Failure()
+ {
+ var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance);
+ sut.Dispose();
+
+ var result = sut.Evaluate("alarm", "return true;", new Dictionary());
+
+ result.Success.ShouldBeFalse();
+ result.Reason!.ShouldContain("disposed");
+ }
+}