diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/DeployCompileValidator.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/DeployCompileValidator.cs
new file mode 100644
index 00000000..d2241395
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/DeployCompileValidator.cs
@@ -0,0 +1,117 @@
+using System.Text.Json;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
+
+namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
+
+///
+/// Pure, thread-safe deploy-time gate: given a flattened-configuration JSON, it
+/// compiles every script, every alarm on-trigger script, and every Expression
+/// trigger just as does at
+/// runtime — but WITHOUT touching any actor state. It returns the collected
+/// compile errors so the Deployment Manager can reject a deployment whose scripts
+/// do not compile on the site (design spec: site-side compile failure rejects the
+/// deployment; no partial state is applied).
+///
+/// Because it holds no actor state and only calls the (stateless)
+/// , it is safe to run off the singleton
+/// mailbox on a Task.Run thread — the deploy path never blocks on Roslyn.
+///
+public sealed class DeployCompileValidator
+{
+ private readonly ScriptCompilationService _compilationService;
+
+ /// Initializes a new instance of the .
+ /// The same compilation service the Instance Actor uses at runtime.
+ public DeployCompileValidator(ScriptCompilationService compilationService)
+ => _compilationService = compilationService;
+
+ ///
+ /// Validates every compilable artifact in the flattened configuration.
+ /// Returns an empty list when the whole configuration compiles; otherwise
+ /// one human-readable error per failing artifact. Malformed JSON is itself a
+ /// single error (this also fronts the poison-config case for startup load).
+ ///
+ /// The flattened-configuration JSON to validate.
+ /// A list of compile-error messages; empty if the configuration is clean.
+ public IReadOnlyList Validate(string configJson)
+ {
+ FlattenedConfiguration? config;
+ try
+ {
+ config = JsonSerializer.Deserialize(configJson);
+ }
+ catch (JsonException ex)
+ {
+ return [$"Configuration JSON failed to deserialize: {ex.Message}"];
+ }
+
+ if (config == null)
+ return ["Configuration JSON failed to deserialize: result was null."];
+
+ var errors = new List();
+
+ // Scripts: full execution-path compile against ScriptGlobals.
+ foreach (var script in config.Scripts)
+ {
+ var result = _compilationService.Compile(script.CanonicalName, script.Code);
+ if (!result.IsSuccess)
+ errors.Add($"Script '{script.CanonicalName}': {string.Join("; ", result.Errors)}");
+
+ // Expression-triggered scripts also compile their trigger expression.
+ AddTriggerExpressionError(errors, script.TriggerType, script.TriggerConfiguration,
+ $"script-trigger-{script.CanonicalName}");
+ }
+
+ // Alarms: on-trigger script reference must resolve + compile, and any
+ // Expression trigger must compile — mirrors InstanceActor.CreateChildActors.
+ foreach (var alarm in config.Alarms)
+ {
+ if (!string.IsNullOrEmpty(alarm.OnTriggerScriptCanonicalName))
+ {
+ var triggerScriptDef = config.Scripts
+ .FirstOrDefault(s => s.CanonicalName == alarm.OnTriggerScriptCanonicalName);
+ if (triggerScriptDef == null)
+ {
+ errors.Add($"Alarm '{alarm.CanonicalName}': on-trigger script " +
+ $"'{alarm.OnTriggerScriptCanonicalName}' is not defined.");
+ }
+ else
+ {
+ var result = _compilationService.Compile(
+ $"alarm-trigger-{alarm.CanonicalName}", triggerScriptDef.Code);
+ if (!result.IsSuccess)
+ errors.Add($"Alarm '{alarm.CanonicalName}' on-trigger script " +
+ $"'{alarm.OnTriggerScriptCanonicalName}': {string.Join("; ", result.Errors)}");
+ }
+ }
+
+ AddTriggerExpressionError(errors, alarm.TriggerType, alarm.TriggerConfiguration,
+ $"alarm-trigger-expr-{alarm.CanonicalName}");
+ }
+
+ return errors;
+ }
+
+ ///
+ /// Compiles the boolean trigger expression for an Expression-triggered script
+ /// or alarm and records any failure. No-op for non-Expression triggers or a
+ /// blank/malformed expression config — mirroring
+ /// InstanceActor.CompileTriggerExpression, where those cases yield an
+ /// inert trigger rather than a deployment error.
+ ///
+ private void AddTriggerExpressionError(
+ List errors, string? triggerType, string? triggerConfigJson, string compileName)
+ {
+ if (!string.Equals(triggerType, "Expression", StringComparison.OrdinalIgnoreCase))
+ return;
+
+ var expression = TriggerExpressionGlobals.ExtractExpression(triggerConfigJson);
+ if (expression == null)
+ return;
+
+ var result = _compilationService.CompileTriggerExpression(compileName, expression);
+ if (!result.IsSuccess)
+ errors.Add($"Trigger expression '{compileName}': {string.Join("; ", result.Errors)}");
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Deployment/DeployCompileValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Deployment/DeployCompileValidatorTests.cs
new file mode 100644
index 00000000..81c36ece
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Deployment/DeployCompileValidatorTests.cs
@@ -0,0 +1,102 @@
+using System.Text.Json;
+using Microsoft.Extensions.Logging.Abstractions;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
+
+namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Deployment;
+
+///
+/// Site Runtime S3: the pure deploy-time compile validator. It mirrors the
+/// compile work
+/// does at runtime (scripts, on-trigger scripts, Expression triggers) so the
+/// Deployment Manager can reject a non-compiling deployment before any actor is
+/// created — WITHOUT touching actor state (safe on a Task.Run thread).
+///
+public class DeployCompileValidatorTests
+{
+ private static DeployCompileValidator NewValidator() =>
+ new(new ScriptCompilationService(NullLogger.Instance));
+
+ private static string ConfigWith(string scriptCode) => JsonSerializer.Serialize(new FlattenedConfiguration
+ {
+ Scripts = new List
+ {
+ new() { CanonicalName = "S1", Code = scriptCode, TriggerType = "Call" }
+ },
+ Attributes = new List(),
+ Alarms = new List(),
+ NativeAlarmSources = new List()
+ });
+
+ [Fact]
+ public void BrokenScript_ReturnsErrors()
+ {
+ var errors = NewValidator().Validate(ConfigWith("this is not C# ~~~"));
+ Assert.NotEmpty(errors);
+ Assert.Contains(errors, e => e.Contains("S1"));
+ }
+
+ [Fact]
+ public void ValidScript_ReturnsNoErrors()
+ {
+ Assert.Empty(NewValidator().Validate(ConfigWith("return 1 + 1;")));
+ }
+
+ [Fact]
+ public void BrokenTriggerExpression_ReturnsErrors()
+ {
+ // One Expression-triggered script whose trigger expression "1 +" does not compile.
+ var cfg = JsonSerializer.Serialize(new FlattenedConfiguration
+ {
+ Scripts = new List
+ {
+ new()
+ {
+ CanonicalName = "S1",
+ Code = "return 1;",
+ TriggerType = "Expression",
+ TriggerConfiguration = JsonSerializer.Serialize(new { expression = "1 +" })
+ }
+ },
+ Attributes = new List(),
+ Alarms = new List(),
+ NativeAlarmSources = new List()
+ });
+
+ var errors = NewValidator().Validate(cfg);
+ Assert.NotEmpty(errors);
+ Assert.Contains(errors, e => e.Contains("script-trigger-S1"));
+ }
+
+ [Fact]
+ public void MalformedJson_ReturnsSingleDeserializeError()
+ {
+ var errors = NewValidator().Validate("{ this is not json");
+ Assert.Single(errors);
+ Assert.Contains("deserialize", errors[0], StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void MissingOnTriggerScript_ReturnsError()
+ {
+ var cfg = JsonSerializer.Serialize(new FlattenedConfiguration
+ {
+ Scripts = new List(),
+ Attributes = new List(),
+ Alarms = new List
+ {
+ new()
+ {
+ CanonicalName = "A1",
+ TriggerType = "HiLo",
+ OnTriggerScriptCanonicalName = "DoesNotExist"
+ }
+ },
+ NativeAlarmSources = new List()
+ });
+
+ var errors = NewValidator().Validate(cfg);
+ Assert.Contains(errors, e => e.Contains("A1") && e.Contains("DoesNotExist"));
+ }
+}