diff --git a/docs/requirements/Component-SiteRuntime.md b/docs/requirements/Component-SiteRuntime.md
index eb03f550..ce5b7555 100644
--- a/docs/requirements/Component-SiteRuntime.md
+++ b/docs/requirements/Component-SiteRuntime.md
@@ -239,7 +239,7 @@ flowchart TD
> **Routed multi-attribute reads and writes are one message each (review 2026-10-21 01/SR-P4).** `Route.To().GetAttributes()` / `SetAttributes()` used to cost one Ask — a temp actor, a mailbox round trip and a 30 s timer — per attribute. The Deployment Manager now sends the Instance Actor a single site-local `InstanceGetAttributesRequest` / `InstanceSetAttributesRequest` (`SiteRuntime/Messages/InstanceAttributeBatch.cs`, never on the wire). Reads are answered in one pass from one consistent point in the mailbox. Writes are applied in order exactly as the equivalent single `SetStaticAttributeCommand`s (static → in-memory + coalesced persist, data-sourced → DCL write, unknown/poison values rejected); data-sourced writes are issued concurrently and the reply waits only for those, each still bounded by its own 30 s DCL Ask (the batch Ask allows that 30 s plus 10 s of mailbox slack, so a device timeout or a busy instance mailbox surfaces as that attribute's own failure, not as a whole-batch timeout over writes that were applied). Both replies carry the per-attribute `GetAttributeResponse` / `SetStaticAttributeResponse`, so the routed response is unchanged: a failed device write or unknown attribute is still reported by name.
-> **Loaded overrides are republished to the children and the stream (review 2026-10-21 01/SR-U1).** The override load is asynchronous and lands *after* `PreStart` has already created the Script/Alarm Actors, whose trigger snapshots are seeded from the attribute set at that moment — the deploy **defaults**. Static attributes never re-emit a change on their own, so before this fix every trigger on an instance that restarted, failed over, was enabled or was started by the reconcile sweep evaluated an overridden attribute at its default indefinitely (an Expression alarm on `Mode == "Auto"` never raised), and the Debug View live stream showed the default, while `GetAttribute` answered correctly. The load now republishes each override that changed the in-memory value or quality (quality from the load, no `SourceExecutionId`), with waiter evaluation **off** — nothing can legitimately be waiting on a replay. The site stream / Debug View receive it as an ordinary `AttributeValueChanged`; the children receive it as a site-local **`AttributeValueSeed`** (decision 2026-10-22): **Script Actors adopt the value without running** — the snapshot, a Conditional trigger's truth value and an Expression trigger's last result (evaluated, never fired) are brought up to date, so a restart/failover never re-fires ValueChange/Conditional/Expression scripts and the next real change is judged against the real value; a WhileTrue trigger whose condition already holds is seeded true without starting its re-fire timer. **Alarm Actors evaluate the seed like any change**, so an alarm whose condition already holds raises. A static write that lands before the load is newer than the stored row, so the load skips that attribute rather than rolling it back. Overrides for attributes the deployed config no longer defines are still applied (unchanged) but not republished.
+> **Loaded overrides are republished to the children and the stream (review 2026-10-21 01/SR-U1).** The override load is asynchronous and lands *after* `PreStart` has already created the Script/Alarm Actors, whose trigger snapshots are seeded from the attribute set at that moment — the deploy **defaults**. Static attributes never re-emit a change on their own, so before this fix every trigger on an instance that restarted, failed over, was enabled or was started by the reconcile sweep evaluated an overridden attribute at its default indefinitely (an Expression alarm on `Mode == "Auto"` never raised), and the Debug View live stream showed the default, while `GetAttribute` answered correctly. The load now republishes each override that changed the in-memory value or quality (quality from the load, no `SourceExecutionId`), with waiter evaluation **off** — nothing can legitimately be waiting on a replay. The site stream / Debug View receive it as an ordinary `AttributeValueChanged`; the children receive it as a site-local **`AttributeValueSeed`** (decision 2026-10-22): **Script Actors adopt the value without running** — the snapshot, a Conditional trigger's truth value and an Expression trigger's last result (evaluated, never fired) are brought up to date, so a restart/failover never re-fires ValueChange/Conditional/Expression scripts and the next real change is judged against the real value; a WhileTrue trigger whose condition already holds is seeded true without starting its re-fire timer. A seed evaluation whose task **faults** adopts nothing — it is not replayed as a real `false`, which would stop a running WhileTrue timer and make the next true evaluation fire as a spurious fresh edge (review 2026-10-21 follow-up L4). **Alarm Actors evaluate the seed like any change**, so an alarm whose condition already holds raises. A static write that lands before the load is newer than the stored row, so the load skips that attribute rather than rolling it back. Overrides for attributes the deployed config no longer defines are still applied (unchanged) but not republished.
> **Stopping an instance answers its pending waits (review 2026-10-21 01/SR-M3).** `PostStop` cancels every registered `WaitForAttribute` timeout and replies `Matched=false, TimedOut=false, ErrorMessage="… abandoned: instance stopped"`, so a script parked in `Attributes.WaitAsync` across a redeploy/disable/delete learns why immediately instead of burning its whole wait plus the Ask timeout and reporting a time-out, and no `WaitForAttributeTimeout` lands in dead letters (a monitored health metric).
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptActor.cs
index 856adb15..85a7763d 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptActor.cs
@@ -447,7 +447,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
}
}).PipeTo(self,
success: r => new ExpressionEvalResult(r, seedOnly),
- failure: ex => new ExpressionEvalFailed(ex));
+ failure: ex => new ExpressionEvalFailed(ex, seedOnly));
}
///
@@ -483,11 +483,26 @@ public class ScriptActor : ReceiveActor, IWithTimers
/// existing error path (which also increments the health counter), then reuses the
/// false-result path — clearing in-flight, applying the false edge, and draining any
/// pending evaluation — so a transient scheduler fault cannot permanently park the trigger.
+ /// A faulted SEED evaluation (review 2026-10-21 follow-up L4) carries no truth value at all:
+ /// it only clears in-flight and drains the pending evaluation, adopting nothing — replaying it
+ /// as a real false stopped a running WhileTrue timer, and even as a seeded false it would make
+ /// the next true evaluation look like a fresh edge and fire spuriously.
///
private void HandleExpressionEvalFailed(ExpressionEvalFailed msg)
{
LogExpressionError(msg.Cause);
- HandleExpressionEvalResult(new ExpressionEvalResult(false));
+ if (!msg.SeedOnly)
+ {
+ HandleExpressionEvalResult(new ExpressionEvalResult(false));
+ return;
+ }
+
+ _evalInFlight = false;
+ if (_evalPending)
+ {
+ _evalPending = false;
+ StartExpressionEvaluation(_evalPendingSeedOnly);
+ }
}
///
@@ -929,7 +944,12 @@ public class ScriptActor : ReceiveActor, IWithTimers
/// mapping the actor receives an unhandled Status.Failure and _evalInFlight
/// stays true forever, permanently parking the expression trigger (N2).
///
- internal sealed record ExpressionEvalFailed(Exception Cause);
+ /// The task fault.
+ ///
+ /// Whether the faulted evaluation was a seed (01/SR-U1); a faulted seed adopts nothing
+ /// (review 2026-10-21 follow-up L4).
+ ///
+ internal sealed record ExpressionEvalFailed(Exception Cause, bool SeedOnly = false);
}
// ── Trigger config types ──
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptActorTests.cs
index 3238b275..f2a6f224 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptActorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptActorTests.cs
@@ -589,6 +589,33 @@ public class ScriptActorTests : TestKit, IDisposable
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
+ ///
+ /// Follow-up L4: a SEED evaluation whose task faults carries no truth value. It used to be
+ /// replayed as a REAL false — stopping a running WhileTrue re-fire timer (and arming a
+ /// spurious fire on the next true evaluation). It now adopts nothing.
+ ///
+ [Fact]
+ public void ScriptActor_ExpressionWhileTrue_FaultedSeedEval_DoesNotStopReFiring()
+ {
+ var triggerExpr = CompileTriggerExpression("Attributes[\"Active\"]?.ToString() == \"yes\"");
+ var (actor, instance) = CreateTriggeredActor(
+ "ExprSeedFault",
+ "Expression",
+ "{\"expression\":\"Attributes[\\\"Active\\\"]?.ToString() == \\\"yes\\\"\",\"mode\":\"WhileTrue\"}",
+ TimeSpan.FromMilliseconds(300),
+ triggerExpr);
+
+ actor.Tell(Change("Active", "yes"));
+ instance.ExpectMsg(TimeSpan.FromSeconds(2)); // edge fire
+ instance.ExpectMsg(TimeSpan.FromSeconds(2)); // tick 1
+
+ actor.Tell(new ScriptActor.ExpressionEvalFailed(new ObjectDisposedException("scheduler"), SeedOnly: true));
+
+ // The condition still holds and nothing real said otherwise: the timer keeps re-firing.
+ instance.ExpectMsg(TimeSpan.FromSeconds(2));
+ instance.ExpectMsg(TimeSpan.FromSeconds(2));
+ }
+
/// Verifies that an on-true expression trigger fires once per false-to-true edge.
[Fact]
public void ScriptActor_ExpressionOnTrue_FiresOncePerFalseToTrueEdge()