diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/HandshakeResetEchoTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/HandshakeResetEchoTests.cs
new file mode 100644
index 00000000..173ffa43
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/HandshakeResetEchoTests.cs
@@ -0,0 +1,166 @@
+using Akka.Actor;
+using Akka.TestKit;
+using Akka.TestKit.Xunit2;
+using Microsoft.Extensions.Logging.Abstractions;
+using System.Text.Json;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
+using ZB.MOM.WW.ScadaBridge.Commons.Types;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
+using ZB.MOM.WW.ScadaBridge.TestSupport;
+
+namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
+
+///
+/// Gitea #36 end-to-end over the REAL waiter and DCL ingest path:
+/// the cache still holds the previous run's CompleteFlag = True, the script's reset
+/// write has NOT echoed back yet. The legacy overload fast-paths on the stale value (the
+/// defect, pinned as documentation); the reset-aware overload holds the batch + trigger until
+/// the reset echo lands, then matches only on the device's fresh True.
+///
+public class HandshakeResetEchoTests : TestKit, IDisposable
+{
+ private const string Conn = "PLC";
+ private const string TriggerTag = "ns=3;s=MoveOutFlag";
+ private const string CompleteTag = "ns=3;s=MoveOutCompleteFlag";
+ private const string BatchTag = "ns=3;s=MoveOutBatchID";
+
+ private readonly SiteStorageService _storage;
+ private readonly ScriptCompilationService _compilationService;
+ private readonly SharedScriptLibrary _sharedScriptLibrary;
+ private readonly TestLocalDb _localDb;
+
+ public HandshakeResetEchoTests()
+ {
+ _localDb = TestLocalDb.CreateTemp("handshake-reset-echo");
+ _storage = new SiteStorageService(_localDb.Db, NullLogger.Instance);
+ _storage.InitializeAsync().GetAwaiter().GetResult();
+ _compilationService = new ScriptCompilationService(NullLogger.Instance);
+ _sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger.Instance);
+ }
+
+ void IDisposable.Dispose()
+ {
+ // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
+ // then dispose the database before deleting — the master connection anchors the WAL.
+ Shutdown();
+ var path = _localDb.Path;
+ _localDb.Dispose();
+ TestLocalDb.DeleteFiles(path);
+ }
+
+ private static FlattenedConfiguration Config() => new()
+ {
+ InstanceUniqueName = "Z28061",
+ Attributes =
+ [
+ new ResolvedAttribute { CanonicalName = "MoveOutFlag", Value = "false", DataType = "Boolean", DataSourceReference = TriggerTag, BoundDataConnectionName = Conn },
+ new ResolvedAttribute { CanonicalName = "MoveOutCompleteFlag", Value = "false", DataType = "Boolean", DataSourceReference = CompleteTag, BoundDataConnectionName = Conn },
+ new ResolvedAttribute { CanonicalName = "MoveOutBatchID", Value = "0", DataType = "Int32", DataSourceReference = BatchTag, BoundDataConnectionName = Conn },
+ ]
+ };
+
+ private (IActorRef Actor, TestProbe Dcl, ScriptRuntimeContext Ctx) Boot()
+ {
+ var dcl = CreateTestProbe();
+ var actor = ActorOf(Props.Create(() => new InstanceActor(
+ "Z28061", JsonSerializer.Serialize(Config()), _storage, _compilationService, _sharedScriptLibrary,
+ null, new SiteRuntimeOptions(), NullLogger.Instance, dcl.Ref)));
+ dcl.ExpectMsg(TimeSpan.FromSeconds(5));
+
+ // The previous run's completion is still in the cache: codec "True", exactly what the
+ // response wait targets.
+ actor.Tell(new TagValueUpdate(Conn, CompleteTag, true, QualityCode.Good, DateTimeOffset.UtcNow));
+
+ var ctx = new ScriptRuntimeContext(
+ actor, _sharedScriptLibrary, currentCallDepth: 0, maxCallDepth: 10,
+ askTimeout: TimeSpan.FromSeconds(5), instanceName: "Z28061", logger: NullLogger.Instance);
+ return (actor, dcl, ctx);
+ }
+
+ private static IReadOnlyDictionary Values(int batchId)
+ => new Dictionary { ["MoveOutBatchID"] = AttributeValueCodec.Encode(batchId) };
+
+ /// The defect, as documentation: the legacy overload answers from the stale cache.
+ [Fact]
+ public async Task LegacyOverload_FastPathsOnThePreviousRunsCachedTrue()
+ {
+ var (_, dcl, ctx) = Boot();
+
+ var call = ctx.WriteBatchAndWait(
+ Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true),
+ "MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(5));
+
+ var batch = dcl.ExpectMsg(TimeSpan.FromSeconds(5));
+ dcl.Reply(new WriteTagBatchResponse(batch.CorrelationId, true, null, DateTimeOffset.UtcNow));
+
+ // No device update at all — and the wait still "matches".
+ Assert.True(await call);
+ }
+
+ [Fact]
+ public async Task ResetAwareOverload_HoldsTheBatchUntilTheResetEchoes_ThenMatchesTheFreshTrue()
+ {
+ var (actor, dcl, ctx) = Boot();
+
+ var call = ctx.WriteBatchResetAndWait(
+ Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true),
+ "MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(10),
+ "MoveOutCompleteFlag", AttributeValueCodec.Encode(false), TimeSpan.FromSeconds(10));
+
+ // Leg 1 reaches the device as a plain tag write; the DCL acks it but the device has
+ // not echoed anything yet.
+ var reset = dcl.ExpectMsg(TimeSpan.FromSeconds(5));
+ Assert.Equal(CompleteTag, reset.TagPath);
+ dcl.Reply(new WriteTagResponse(reset.CorrelationId, true, null, DateTimeOffset.UtcNow));
+
+ // The cache still says True → the reset wait must NOT fast-path, and no batch may go out.
+ dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
+ Assert.False(call.IsCompleted);
+
+ // The device echoes the reset.
+ actor.Tell(new TagValueUpdate(Conn, CompleteTag, false, QualityCode.Good, DateTimeOffset.UtcNow));
+
+ // Only now the batch + trigger.
+ var batch = dcl.ExpectMsg(TimeSpan.FromSeconds(5));
+ Assert.Equal(TriggerTag, batch.TriggerTagPath);
+ Assert.Equal(AttributeValueCodec.Encode(true), AttributeValueCodec.Encode(batch.TriggerValue));
+ Assert.True(batch.Values.ContainsKey(BatchTag));
+ dcl.Reply(new WriteTagBatchResponse(batch.CorrelationId, true, null, DateTimeOffset.UtcNow));
+
+ // Still pending: the cache now holds False.
+ Assert.False(call.IsCompleted);
+
+ // The device completes THIS request.
+ actor.Tell(new TagValueUpdate(Conn, CompleteTag, true, QualityCode.Good, DateTimeOffset.UtcNow));
+
+ var result = await call;
+ Assert.True(result.Matched);
+ Assert.Equal(HandshakePhase.Response, result.Phase);
+ Assert.Equal(true, result.Value);
+ Assert.Equal("Good", result.Quality);
+ }
+
+ [Fact]
+ public async Task ResetAwareOverload_NoEcho_TimesOutInResetPhase_AndNeverWritesTheBatch()
+ {
+ var (_, dcl, ctx) = Boot();
+
+ var call = ctx.WriteBatchResetAndWait(
+ Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true),
+ "MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(10),
+ "MoveOutCompleteFlag", AttributeValueCodec.Encode(false), TimeSpan.FromMilliseconds(700));
+
+ var reset = dcl.ExpectMsg(TimeSpan.FromSeconds(5));
+ dcl.Reply(new WriteTagResponse(reset.CorrelationId, true, null, DateTimeOffset.UtcNow));
+
+ var result = await call;
+ Assert.False(result.Matched);
+ Assert.True(result.TimedOut);
+ Assert.True(result.ResetFailed);
+ dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
+ }
+}