diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/InboundScriptHost.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/InboundScriptHost.cs
index 98212fbb..984425ae 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/InboundScriptHost.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/InboundScriptHost.cs
@@ -28,6 +28,14 @@ public class InboundScriptHost
///
public DatabaseAccessor Database { get; } = new();
+ ///
+ /// Leveled per-run logging. Editor mirror of
+ /// ZB.MOM.WW.ScadaBridge.InboundAPI.InboundScriptLogger (step logs design 2026-09-09 D4);
+ /// shares with the Test Run sandbox because only the signatures
+ /// matter here — analysis never invokes it.
+ ///
+ public SandboxLog Log { get; } = new();
+
///
/// The cancellation token for the operation.
///
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxInboundScriptHost.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxInboundScriptHost.cs
index 238d54eb..dc7de29a 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxInboundScriptHost.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxInboundScriptHost.cs
@@ -26,6 +26,13 @@ public class SandboxInboundScriptHost
/// Gets the database accessor; every call throws in a test run.
public DatabaseAccessor Database { get; } = new();
+ ///
+ /// Sandbox mirror of the inbound runtime's Log global (step logs design 2026-09-09 D4).
+ /// Present so a method script that logs BINDS in the Test Run editor; lines go to the Test Run
+ /// console pane and are never audited.
+ ///
+ public SandboxLog Log { get; } = new();
+
///
/// Mirror of ZB.MOM.WW.ScadaBridge.InboundAPI.InboundDatabaseHelper — signatures match
/// the runtime helper so the same user code compiles, but every call throws because a
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs
index 662c3ab6..cce4cda0 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs
@@ -87,6 +87,58 @@ public class SandboxScriptHost
/// would show a spurious CS1061 on a script that deploys and runs perfectly.
///
public SandboxAlarmsAccessor Alarms { get; } = new();
+
+ ///
+ /// Sandbox mirror of the site runtime's Log global (step logs design 2026-09-09 D4).
+ /// Present so a script that logs BINDS in the Test Run editor; lines go to the Test Run
+ /// console pane and are never audited. Delegates to
+ /// exactly as the runtime's ScriptGlobals.Log => Instance.Log does, so
+ /// Log and Instance.Log are the same object here too.
+ ///
+ public SandboxLog Log => Instance.Log;
+}
+
+///
+/// Sandbox mirror of ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.ScriptLogger (step logs
+/// design 2026-09-09 D4). Signature-faithful so the same script body compiles here and at a site,
+/// but a Test Run has no execution to trace and no audit trail: every line is written to
+/// , which the sandbox captures into
+/// SandboxRunResult.ConsoleOutput (see ) and shows in
+/// the editor's console pane. Nothing here reaches the node log or the audit log.
+///
+public class SandboxLog
+{
+ /// Writes a debug-level line to the Test Run console pane.
+ /// The message text.
+ /// An optional exception, rendered on a following line.
+ public void Debug(string message, Exception? exception = null) => Write("debug", message, exception);
+
+ /// Writes an information-level line to the Test Run console pane.
+ /// The message text.
+ /// An optional exception, rendered on a following line.
+ public void Info(string message, Exception? exception = null) => Write("info", message, exception);
+
+ /// Writes a warning-level line to the Test Run console pane.
+ /// The message text.
+ /// An optional exception, rendered on a following line.
+ public void Warn(string message, Exception? exception = null) => Write("warn", message, exception);
+
+ ///
+ /// Writes an error-level line to the Test Run console pane. Like the runtime logger, this
+ /// does NOT fail the run — it is a message, not a throw.
+ ///
+ /// The message text.
+ /// An optional exception, rendered on a following line.
+ public void Error(string message, Exception? exception = null) => Write("error", message, exception);
+
+ private static void Write(string level, string message, Exception? exception)
+ {
+ Console.Out.WriteLine($"[{level}] {message}");
+ if (exception != null)
+ {
+ Console.Out.WriteLine(exception.ToString());
+ }
+ }
}
///
@@ -193,6 +245,12 @@ public class SandboxInstanceContext
///
public SandboxScriptCallHelper Scripts { get; }
+ ///
+ /// Sandbox mirror of ScriptRuntimeContext.Log (step logs design 2026-09-09 D4), so
+ /// Instance.Log.Info("…") binds and prints in the Test Run console pane.
+ ///
+ public SandboxLog Log { get; } = new();
+
///
/// Initializes a new instance of the SandboxInstanceContext.
///
diff --git a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs
index 40e4f6cc..8055f80f 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs
@@ -67,6 +67,9 @@ public sealed class ScriptCompileSurface
/// Mirrors ScriptGlobals.Alarms.
public CompileAlarmsAccessor Alarms => throw new NotSupportedException(CompileOnly);
+ /// Mirrors ScriptGlobals.Log.
+ public CompileLog Log => throw new NotSupportedException(CompileOnly);
+
/// Compile-only mirror of ScriptRuntimeContext (the Instance global).
public sealed class CompileInstance
{
@@ -104,6 +107,38 @@ public sealed class ScriptCompileSurface
/// Mirrors ScriptRuntimeContext.Alarms.
public CompileAlarmsAccessor Alarms => throw new NotSupportedException(CompileOnly);
+
+ /// Mirrors ScriptRuntimeContext.Log.
+ public CompileLog Log => throw new NotSupportedException(CompileOnly);
+ }
+
+ ///
+ /// Compile-only mirror of ScriptLogger (step logs design 2026-09-09 D4) — the
+ /// Log global a script writes leveled lines through. The optional
+ /// exception parameter is mirrored so Log.Warn("…", ex) binds at design
+ /// time exactly as it does at the site.
+ ///
+ public sealed class CompileLog
+ {
+ /// Mirrors ScriptLogger.Debug.
+ /// The message text.
+ /// An optional exception to attach to the line.
+ public void Debug(string message, Exception? exception = null) => throw new NotSupportedException(CompileOnly);
+
+ /// Mirrors ScriptLogger.Info.
+ /// The message text.
+ /// An optional exception to attach to the line.
+ public void Info(string message, Exception? exception = null) => throw new NotSupportedException(CompileOnly);
+
+ /// Mirrors ScriptLogger.Warn.
+ /// The message text.
+ /// An optional exception to attach to the line.
+ public void Warn(string message, Exception? exception = null) => throw new NotSupportedException(CompileOnly);
+
+ /// Mirrors ScriptLogger.Error.
+ /// The message text.
+ /// An optional exception to attach to the line.
+ public void Error(string message, Exception? exception = null) => throw new NotSupportedException(CompileOnly);
}
///
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs
index 9799f3f7..9601c2e4 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs
@@ -574,6 +574,40 @@ public class ScriptAnalysisServiceTests
Assert.Contains("hello-sandbox", result.ConsoleOutput);
}
+ [Fact]
+ public async Task RunInSandbox_LogGlobal_WritesToConsoleOutput()
+ {
+ // Flow Debugger step logs (design 2026-09-09 D4): the sandbox `Log` mirror exists so
+ // a script that logs BINDS in the Test Run editor, and its lines land in the console
+ // pane (the sandbox has no audit trail — nothing here is written to AuditLog).
+ var result = await _svc.RunInSandboxAsync(
+ new SandboxRunRequest(
+ "Log.Info(\"hello\"); return 1;",
+ Parameters: null,
+ TimeoutSeconds: null),
+ CancellationToken.None);
+
+ Assert.True(result.Success, result.Error);
+ Assert.Contains("[info] hello", result.ConsoleOutput);
+ }
+
+ [Fact]
+ public async Task RunInSandbox_InboundScript_LogGlobal_WritesToConsoleOutput()
+ {
+ // Same mirror on the inbound Test Run host (SandboxInboundScriptHost) — an API
+ // method script's Log.* calls must bind and surface in the console pane too.
+ var result = await _svc.RunInSandboxAsync(
+ new SandboxRunRequest(
+ "Log.Info(\"hello\"); return 1;",
+ Parameters: null,
+ TimeoutSeconds: null,
+ Kind: ScriptKind.InboundApi),
+ CancellationToken.None);
+
+ Assert.True(result.Success, result.Error);
+ Assert.Contains("[info] hello", result.ConsoleOutput);
+ }
+
[Fact]
public async Task RunInSandbox_ConcurrentRuns_DoNotCrossContaminateConsoleOutput()
{
@@ -661,6 +695,40 @@ public class ScriptAnalysisServiceTests
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA"));
}
+ [Fact]
+ public void InboundScript_Log_DiagnosesClean()
+ {
+ // Flow Debugger step logs (design 2026-09-09 D4): `Log` is a shipped inbound-script
+ // global (InboundScriptContext.Log). The EDITOR host is a third mirror alongside the
+ // compile surface and the Test Run sandbox — without `Log` on InboundScriptHost a
+ // logging method script shows a spurious CS0103 while deploying and running fine.
+ var resp = _svc.Diagnose(new DiagnoseRequest(
+ Code: "Log.Info(\"x\");\n"
+ + "Log.Debug(\"d\");\n"
+ + "Log.Warn(\"w\", new System.Exception(\"boom\"));\n"
+ + "Log.Error(\"e\");",
+ Kind: ScriptKind.InboundApi));
+
+ Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS"));
+ Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA"));
+ }
+
+ [Fact]
+ public void SiteScript_Log_DiagnosesClean()
+ {
+ // The other half of GlobalsTypeFor: every non-inbound kind analyses against
+ // SandboxScriptHost, so the same `Log` global must resolve there too — both at
+ // the top level and via `Instance.Log`.
+ var resp = _svc.Diagnose(new DiagnoseRequest(
+ Code: "Log.Info(\"x\");\n"
+ + "Log.Warn(\"w\", new System.Exception(\"boom\"));\n"
+ + "Instance.Log.Info(\"via instance\");",
+ Kind: ScriptKind.Template));
+
+ Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS"));
+ Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA"));
+ }
+
[Fact]
public void InboundScript_Database_DiagnosesClean()
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs
index 90b2bcfb..ff4deb26 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs
@@ -139,6 +139,25 @@ public class RoslynScriptCompilerTests
Assert.Empty(diagnostics);
}
+ [Fact]
+ public void CompilesCleanly_ForTheLogGlobal()
+ {
+ // Flow Debugger step logs (design 2026-09-09 D4): `Log` is a script-reachable
+ // global on the runtime ScriptGlobals AND on ScriptRuntimeContext, so both the
+ // top-level `Log` and `Instance.Log` must bind at design time — otherwise a
+ // script that logs would pass the editor but fail the deploy gate.
+ const string code = """
+ Log.Info("a");
+ Log.Warn("b", new System.Exception("x"));
+ Log.Error("c");
+ Log.Debug("d");
+ Instance.Log.Info("e");
+ """;
+
+ var diagnostics = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
+ Assert.Empty(diagnostics);
+ }
+
[Fact]
public void Compile_TwiceWithSharedResolver_StillCompilesCleanly()
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs
index 77c915ab..748c147d 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs
@@ -212,6 +212,24 @@ public class ScriptTrustValidatorTests
Assert.Empty(ScriptTrustValidator.FindViolations(code));
}
+ [Fact]
+ public void Allows_TheLogGlobal()
+ {
+ // Flow Debugger step logs (design 2026-09-09 D4). The trust model is a DENY-list
+ // over API roots, not an allow-list of context members, so the `Log` global needs
+ // no policy entry — including the overload that attaches a System.Exception, which
+ // is a permitted BCL type. This pins that a logging script is not blocked.
+ const string code = """
+ Log.Info("x");
+ Log.Debug("d");
+ Log.Warn("w", new System.Exception("boom"));
+ Log.Error("e");
+ Instance.Log.Info("via instance");
+ """;
+
+ Assert.Empty(ScriptTrustValidator.FindViolations(code));
+ }
+
[Fact]
public void ForbiddenScopes_DoNotCover_TheCommonsScriptSurface()
{