diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ScriptingOptionsBinding.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ScriptingOptionsBinding.cs
new file mode 100644
index 00000000..1155748b
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ScriptingOptionsBinding.cs
@@ -0,0 +1,23 @@
+using Microsoft.Extensions.Configuration;
+using ZB.MOM.WW.OtOpcUa.Core.Scripting;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
+
+///
+/// Binds the node-wide Scripting section into and clamps it
+/// (). One place, so Program.cs and its unit test bind the same
+/// way and a misspelled key cannot silently fall back to the compiled defaults unnoticed.
+///
+public static class ScriptingOptionsBinding
+{
+ /// Binds and clamps the Scripting section.
+ /// The host configuration.
+ /// One line per clamped value, for the caller to log as a Warning.
+ /// The clamped options; the compiled defaults when the section is absent.
+ public static ScriptingOptions Bind(IConfiguration configuration, out IReadOnlyList adjustments)
+ {
+ ArgumentNullException.ThrowIfNull(configuration);
+ var bound = configuration.GetSection(ScriptingOptions.SectionName).Get() ?? new ScriptingOptions();
+ return bound.Sanitized(out adjustments);
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
index 70ecc606..c3c105ab 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
@@ -370,8 +370,8 @@ if (hasDriver)
// Warning rather than an OverflowException inside a script run. The one instance is shared by every script
// seam on the node: the virtual-tag evaluator below, the scripted-alarm engine (DriverHostActor, via
// WithOtOpcUaRuntimeActors) and every Calculation driver (DriverFactoryBootstrap).
- var scriptingOptions = (builder.Configuration.GetSection(ScriptingOptions.SectionName).Get()
- ?? new ScriptingOptions()).Sanitized(out var scriptingAdjustments);
+ var scriptingOptions = ZB.MOM.WW.OtOpcUa.Host.Configuration.ScriptingOptionsBinding.Bind(
+ builder.Configuration, out var scriptingAdjustments);
foreach (var adjustment in scriptingAdjustments)
Log.Warning("Scripting misconfiguration detected at startup: {Adjustment}", adjustment);
builder.Services.AddSingleton(scriptingOptions);
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/ScriptingOptionsBindingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/ScriptingOptionsBindingTests.cs
new file mode 100644
index 00000000..f717d6c5
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/ScriptingOptionsBindingTests.cs
@@ -0,0 +1,64 @@
+using Microsoft.Extensions.Configuration;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Scripting;
+using ZB.MOM.WW.OtOpcUa.Host.Configuration;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
+
+///
+/// Review m-1: the Scripting section binds by the documented key names and is clamped at bind. Each
+/// configured value differs from the compiled default, so a misspelled key (which the binder would silently
+/// ignore, falling back to the default) goes red.
+///
+public sealed class ScriptingOptionsBindingTests
+{
+ private static IConfiguration Config(Dictionary values) =>
+ new ConfigurationBuilder().AddInMemoryCollection(values).Build();
+
+ [Fact]
+ public void Every_documented_key_binds_to_its_property()
+ {
+ var bound = ScriptingOptionsBinding.Bind(Config(new()
+ {
+ ["Scripting:MaxAllocBytesPerRun"] = "8388608",
+ ["Scripting:QuarantineAfterConsecutiveTimeouts"] = "7",
+ ["Scripting:StuckOrphanQuarantineFactor"] = "4.5",
+ // Sibling keys owned by the script-log pipeline must not disturb the bind.
+ ["Scripting:LogFilePath"] = "logs/x-.log",
+ ["Scripting:LogTopicMinLevel"] = "Warning",
+ }), out var adjustments);
+
+ adjustments.ShouldBeEmpty();
+ bound.MaxAllocBytesPerRun.ShouldBe(8L * 1024 * 1024);
+ bound.QuarantineAfterConsecutiveTimeouts.ShouldBe(7);
+ bound.StuckOrphanQuarantineFactor.ShouldBe(4.5);
+ }
+
+ [Fact]
+ public void Out_of_range_values_are_clamped_at_bind_and_reported()
+ {
+ var bound = ScriptingOptionsBinding.Bind(Config(new()
+ {
+ ["Scripting:StuckOrphanQuarantineFactor"] = "1e300",
+ ["Scripting:MaxAllocBytesPerRun"] = "1024",
+ ["Scripting:QuarantineAfterConsecutiveTimeouts"] = "0",
+ }), out var adjustments);
+
+ bound.StuckOrphanQuarantineFactor.ShouldBe(ScriptingOptions.MaxStuckOrphanQuarantineFactor);
+ bound.MaxAllocBytesPerRun.ShouldBe(ScriptingOptions.MinMaxAllocBytesPerRun);
+ bound.QuarantineAfterConsecutiveTimeouts.ShouldBe(1);
+ adjustments.Count.ShouldBe(3);
+ }
+
+ [Fact]
+ public void An_absent_section_yields_the_compiled_defaults()
+ {
+ var bound = ScriptingOptionsBinding.Bind(Config(new()), out var adjustments);
+
+ adjustments.ShouldBeEmpty();
+ bound.MaxAllocBytesPerRun.ShouldBe(ScriptingOptions.DefaultMaxAllocBytesPerRun);
+ bound.QuarantineAfterConsecutiveTimeouts.ShouldBe(ScriptingOptions.DefaultQuarantineAfterConsecutiveTimeouts);
+ bound.StuckOrphanQuarantineFactor.ShouldBe(ScriptingOptions.DefaultStuckOrphanQuarantineFactor);
+ }
+}