diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/ApplyAck.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/ApplyAck.cs
index 6cb593cd..2431bd41 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/ApplyAck.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/ApplyAck.cs
@@ -27,4 +27,12 @@ public sealed record ApplyAck(
string? FailureReason,
CorrelationId CorrelationId,
bool Degraded = false,
- string? DegradedReason = null);
+ string? DegradedReason = null)
+{
+ ///
+ /// Gitea #601 — prefix of NodeDeploymentState.FailureReason on an Applied row whose
+ /// address-space apply was degraded. Shared by central (which writes it from the ack) and a node with
+ /// a ConfigDb (which writes its own row), so both spell the row identically.
+ ///
+ public const string DegradedReasonPrefix = "Applied degraded: ";
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs
index c35fd0d5..d45a20e4 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs
@@ -93,7 +93,7 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
/// degraded node DID apply the revision, so its status stays
/// and the degradation rides the existing reason column, which /fleet already renders.
///
- public const string DegradedReasonPrefix = "Applied degraded: ";
+ public const string DegradedReasonPrefix = ApplyAck.DegradedReasonPrefix;
///
/// Gitea #601 — whether a NodeDeploymentState row records a degraded apply: status
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
index af119dc4..6bb1f3f4 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
@@ -2588,6 +2588,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
if (degraded)
{
+ // #601 review — the apply wrote this node's own row Applied/null before the outcome was known. Central
+ // adds the reason from the ack only when it ACCEPTS the ack (a timed-out or superseded deployment's ack
+ // is discarded), so a node with a ConfigDb records it itself; a driver-only node no-ops here.
+ UpsertNodeDeploymentState(pending.DeploymentId, NodeDeploymentStatus.Applied,
+ ApplyAck.DegradedReasonPrefix + (reason ?? "address-space apply degraded"));
OtOpcUaTelemetry.DeploymentApplied.Add(1, new KeyValuePair("outcome", "degraded"));
_log.Warning(
"DriverHost {Node}: deployment {Id} (rev {Rev}) applied DEGRADED — {Reason}. Acking Applied+degraded; re-dispatching the same revision re-applies it",
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDegradedAckTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDegradedAckTests.cs
index 570d4e48..5da97d25 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDegradedAckTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDegradedAckTests.cs
@@ -172,6 +172,33 @@ public sealed class DriverHostActorDegradedAckTests : RuntimeActorTestBase
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
+ [Fact]
+ public void A_degraded_apply_records_its_reason_on_the_local_NodeDeploymentState_row()
+ {
+ // #601 review — a node WITH a ConfigDb writes its own row Applied/null before the outcome is known.
+ // Central later adds the reason from the ack, but only when it accepts that ack (a stale/timed-out
+ // deployment's ack is discarded), so the node must not leave its own row reading clean.
+ var (db, deploymentId) = SeedOneTagDeployment();
+ var coordinator = CreateTestProbe();
+ var publish = CreateTestProbe();
+ var actor = Sys.ActorOf(DriverHostActor.Props(
+ db, TestNode, coordinator.Ref, localRoles: new HashSet { "driver" },
+ opcUaPublishActor: publish.Ref));
+
+ actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
+ var rebuild = publish.FishForMessage(_ => true, PresenceBudget);
+ publish.Reply(new OpcUaPublishActor.RebuildCompleted(rebuild.Correlation, rebuild.DeploymentId, true, "failedNodes=3"));
+ coordinator.ExpectMsg(PresenceBudget).Degraded.ShouldBeTrue();
+
+ AwaitAssert(() =>
+ {
+ using var ctx = db.CreateDbContext();
+ var row = ctx.NodeDeploymentStates.Single(r => r.NodeId == TestNode.Value && r.DeploymentId == deploymentId.Value);
+ row.Status.ShouldBe(ZB.MOM.WW.OtOpcUa.Configuration.Enums.NodeDeploymentStatus.Applied);
+ row.FailureReason.ShouldBe(ApplyAck.DegradedReasonPrefix + "failedNodes=3");
+ }, PresenceBudget);
+ }
+
private (IDbContextFactory Db, DeploymentId Id) SeedOneTagDeployment()
{
var db = NewInMemoryDbFactory();