diff --git a/docs/plans/2026-06-07-virtualtag-script-memory.md.tasks.json b/docs/plans/2026-06-07-virtualtag-script-memory.md.tasks.json
index 169bf54a..e2996da5 100644
--- a/docs/plans/2026-06-07-virtualtag-script-memory.md.tasks.json
+++ b/docs/plans/2026-06-07-virtualtag-script-memory.md.tasks.json
@@ -4,7 +4,7 @@
{"id": 1, "subject": "Task 1: A0 — extract script types into lean Roslyn-free assembly", "status": "completed", "classification": "high-risk"},
{"id": 2, "subject": "Task 2: A0 measurement gate — re-run probe, confirm ~11x drop", "status": "completed", "classification": "small", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: A — passthrough fast-path in evaluator", "status": "completed", "classification": "small", "blockedBy": [1]},
- {"id": 4, "subject": "Task 4: warn-only deploy guardrail", "status": "pending", "classification": "standard", "blockedBy": [1]},
+ {"id": 4, "subject": "Task 4: warn-only deploy guardrail", "status": "completed", "classification": "standard", "blockedBy": [1]},
{"id": 5, "subject": "Task 5: live docker-dev verification (1036-vtag overlay, no OOM)", "status": "pending", "classification": "standard", "blockedBy": [1, 3, 4]}
],
"lastUpdated": "2026-06-07"
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs
index eb31b42e..786fbc4f 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs
@@ -10,6 +10,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
@@ -105,6 +106,28 @@ public sealed class AdminOperationsActor : ReceiveActor
return;
}
+ // Warn-only compile-cost guardrail (NEVER blocks). Each genuinely-compiled (non-passthrough)
+ // script costs ~1.66 MiB RSS per node to materialize via Roslyn (measured post-A0; design doc
+ // 2026-06-07). Passthrough "mirror" scripts compile to ~nothing, so they are excluded; distinct
+ // sources are counted (the compile cache keys on source, so duplicates collapse to one unit).
+ // This only surfaces the estimate to the operator — the DraftValidator gate above is the hard
+ // reject; this advisory rides in the Accepted Message so the UI can show it.
+ const double PerScriptMiB = 1.66;
+ var scriptSources = await db.Scripts.AsNoTracking().Select(s => s.SourceCode).ToListAsync();
+ var compiled = scriptSources
+ .Where(src => !PassthroughScript.TryMatch(src, out _))
+ .Distinct(StringComparer.Ordinal)
+ .Count();
+ string? advisory = null;
+ if (compiled > 0)
+ {
+ var estMiB = compiled * PerScriptMiB;
+ _log.Warning(
+ "StartDeployment: {Compiled} script(s) will compile (~{EstMiB:F0} MiB RSS per node); ensure node mem_limit covers it",
+ compiled, estMiB);
+ advisory = $"{compiled} script(s) will compile (~{estMiB:F0} MiB/node)";
+ }
+
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(db);
var deploymentId = DeploymentId.NewId();
var revHash = RevisionHash.Parse(artifact.RevisionHash);
@@ -136,7 +159,7 @@ public sealed class AdminOperationsActor : ReceiveActor
StartDeploymentOutcome.Accepted,
deploymentId,
revHash,
- Message: null,
+ Message: advisory,
msg.CorrelationId));
}
catch (Exception ex)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj
index 6993960c..2e937405 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj
@@ -23,6 +23,9 @@
+
+
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs
index 39840f33..b52f9905 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs
@@ -189,6 +189,120 @@ public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase
verify.Deployments.Count().ShouldBe(0);
}
+ /// Verifies the warn-only compile-cost advisory: seeding N distinct non-passthrough
+ /// (genuinely-compiled) Script rows yields an
+ /// deploy whose carries a compile-cost advisory.
+ /// The guardrail NEVER rejects — it only surfaces the estimated RSS pressure to the operator.
+ [Fact]
+ public void StartDeployment_warns_when_many_scripts_will_compile()
+ {
+ const int n = 10;
+ var dbFactory = NewInMemoryDbFactory();
+ using (var db = dbFactory.CreateDbContext())
+ {
+ for (var i = 0; i < n; i++)
+ {
+ db.Scripts.Add(new Configuration.Entities.Script
+ {
+ ScriptId = $"s-{i}",
+ Name = $"script-{i}",
+ SourceCode = $"return (int)ctx.GetTag(\"a\").Value + {i};", // distinct, non-passthrough
+ SourceHash = $"hash-{i}",
+ });
+ }
+ db.SaveChanges();
+ }
+
+ var coordinator = CreateTestProbe("coord");
+ var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty()));
+
+ actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
+
+ // Still dispatches — the advisory is non-blocking.
+ coordinator.ExpectMsg(TimeSpan.FromSeconds(3));
+
+ var reply = ExpectMsg(TimeSpan.FromSeconds(3));
+ reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
+ reply.Message.ShouldNotBeNull();
+ reply.Message.ShouldContain("will compile");
+ reply.Message.ShouldContain($"{n} script"); // the distinct compiled count
+
+ using var verify = dbFactory.CreateDbContext();
+ verify.Deployments.Count().ShouldBe(1);
+ }
+
+ /// Verifies that passthrough "mirror" scripts (return ctx.GetTag("X").Value;)
+ /// compile to ~nothing and therefore do NOT count toward the compile-cost advisory: a deploy
+ /// of only passthrough scripts is with no advisory.
+ [Fact]
+ public void StartDeployment_passthrough_scripts_do_not_count()
+ {
+ var dbFactory = NewInMemoryDbFactory();
+ using (var db = dbFactory.CreateDbContext())
+ {
+ for (var i = 0; i < 5; i++)
+ {
+ db.Scripts.Add(new Configuration.Entities.Script
+ {
+ ScriptId = $"mirror-{i}",
+ Name = $"mirror-{i}",
+ SourceCode = $"return ctx.GetTag(\"tag-{i}\").Value;", // passthrough mirror — costs ~nothing
+ SourceHash = $"mhash-{i}",
+ });
+ }
+ db.SaveChanges();
+ }
+
+ var coordinator = CreateTestProbe("coord");
+ var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty()));
+
+ actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
+
+ coordinator.ExpectMsg(TimeSpan.FromSeconds(3));
+
+ var reply = ExpectMsg(TimeSpan.FromSeconds(3));
+ reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
+ // No compiled scripts → no advisory emitted.
+ (reply.Message is null || !reply.Message.Contains("will compile")).ShouldBeTrue();
+ }
+
+ /// Verifies the advisory counts DISTINCT non-passthrough sources: many rows sharing one
+ /// identical source collapse to a single compiled unit (the compile cache keys on source), so the
+ /// advisory reports 1 — not the row count.
+ [Fact]
+ public void StartDeployment_duplicate_sources_collapse_to_one_compiled_unit()
+ {
+ const string identical = "return (int)ctx.GetTag(\"a\").Value + 1;";
+ var dbFactory = NewInMemoryDbFactory();
+ using (var db = dbFactory.CreateDbContext())
+ {
+ for (var i = 0; i < 7; i++)
+ {
+ db.Scripts.Add(new Configuration.Entities.Script
+ {
+ ScriptId = $"dup-{i}",
+ Name = $"dup-{i}",
+ SourceCode = identical, // same source across all rows
+ SourceHash = "dup-hash",
+ });
+ }
+ db.SaveChanges();
+ }
+
+ var coordinator = CreateTestProbe("coord");
+ var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty()));
+
+ actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
+
+ coordinator.ExpectMsg(TimeSpan.FromSeconds(3));
+
+ var reply = ExpectMsg(TimeSpan.FromSeconds(3));
+ reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
+ reply.Message.ShouldNotBeNull();
+ reply.Message.ShouldContain("will compile");
+ reply.Message.ShouldContain("1 script(s) will compile");
+ }
+
/// Verifies that starting a deployment is refused when another is in flight.
[Fact]
public void StartDeployment_refuses_when_another_is_in_flight()