diff --git a/docs/plans/2026-06-17-waitfor-deferred-items.md b/docs/plans/2026-06-17-waitfor-deferred-items.md
index 696a87f6..f273120d 100644
--- a/docs/plans/2026-06-17-waitfor-deferred-items.md
+++ b/docs/plans/2026-06-17-waitfor-deferred-items.md
@@ -219,8 +219,8 @@ WD-1 must add `RequireGoodQuality` ONLY as a **trailing defaulted** ctor param o
---
-## Out of scope (explicit)
+## Out of scope (explicit — at original authoring)
-- Routed `WaitForAttribute` is NOT wired into the CentralUI Test-Run sandbox (`ISandboxInstanceGateway`/`SandboxInstanceGateway`); production inbound scripts get it. Follow-up if Test-Run parity is wanted.
-- No predicate or quality flag across the wire (§6 is value-equality only, per spec).
+- ~~Routed `WaitForAttribute` is NOT wired into the CentralUI Test-Run sandbox~~ **DELIVERED 2026-07-10 (deferred-work #14, full fidelity):** value-equality `Attributes.WaitAsync`/`WaitForAsync` in the sandbox now route to the bound instance via `ISandboxInstanceGateway.WaitForAttributeAsync` → `CommunicationService.RouteToWaitForAttributeAsync`. Predicate overloads stay unsupported (an in-process lambda cannot be routed) and throw a labelled `ScriptSandboxException`.
+- ~~No quality flag across the wire~~ **Superseded:** `RouteToWaitForAttributeRequest` gained an additive trailing `bool RequireGoodQuality = false`, honored by the site handler, so quality-gated waits route too. Predicate matching is still value-equality-only across the wire (no cross-process lambda).
- No docker redeploy (no cluster-runtime config change; additive script surface only).
diff --git a/docs/plans/2026-07-08-deferred-work-register.md b/docs/plans/2026-07-08-deferred-work-register.md
index 1a92e59f..946af816 100644
--- a/docs/plans/2026-07-08-deferred-work-register.md
+++ b/docs/plans/2026-07-08-deferred-work-register.md
@@ -23,7 +23,6 @@ them and are removed from this table when that plan's task lands.
| 10 | Aggregated live alarm stream for Alarm Summary | m7 design :252 | Snapshot fan-out acceptable at current instance counts | Alarm Summary latency complaints or >~50 instances/site |
| 11 | Central-persisted OPC UA cert-trust audit | m7 follow-ups | Broadcast-to-both-nodes covers HA | Governance/audit requirement for trust decisions |
| 12 | Native-alarm-source-override CSV import — **Central UI `InstanceConfigure` upload affordance only** (CLI + Management API + parser shipped 2026-07-10, see Resolved) | m7 follow-ups | CLI/API path closes the operator parity gap; the Blazor upload button is polish | First request to bulk-import native sources from the UI rather than the CLI |
-| 14 | WaitForAttribute in Test-Run sandbox | waitfor-deferred :222 | Test-Run parity gap; production unaffected (sandbox throws a clearly-labelled ScriptSandboxException; deploy to a site to exercise) | Author feedback on Test-Run fidelity |
| 17 | Unified notifications+site-calls outbox page | stillpending :118 | Explicit M9 decision to keep two pages | Operator confusion reports |
| 18 | Folder drag-drop | same, [PERM] | Permanently closed; menu reorder shipped | — (closed) |
| 19 | Bundle signing / cluster-to-cluster pull / differential bundles | transport-design :402 | v1 manifest hash + AES-GCM held sufficient | Non-repudiation requirement across orgs |
@@ -37,6 +36,7 @@ Rows removed from the Deferred table above once confirmed shipped. Kept here for
| 7 | SecuredWrite audit rows leave SourceNode NULL | Resolved (PLAN-07): `ManagementActor.EmitSecuredWriteAuditAsync` routes through `ICentralAuditWriter`, which stamps `SourceNode` (`central-a`/`central-b`) from `INodeIdentityProvider`. |
| 12 (CLI/API) | Native-alarm-source-override CSV import | Shipped 2026-07-10: shared `CsvLineSplitter`, `NativeAlarmSourceOverrideCsvParser`, bulk all-or-nothing `SetInstanceNativeAlarmSourceOverridesCommand` + ManagementActor handler (Deployer-gated), CLI `instance native-alarm-source import --file`, parser/CLI/handler tests. **UI upload affordance still pending — see row 12 above.** |
| 13 | WaitForAttribute quality-gated ("Good"-only) mode | Already implemented (Commons `WaitForAttribute.RequireGoodQuality`, enforced in `InstanceActor`, threaded through `ScriptRuntimeContext`, tested in `InstanceActorWaitForAttributeTests`). Stale "planned enhancement" doc line corrected 2026-07-10. |
+| 14 | WaitForAttribute in Test-Run sandbox | Shipped 2026-07-10 (full fidelity): sandbox `Attributes.WaitAsync`/`WaitForAsync` (value-equality) route to the bound instance via `ISandboxInstanceGateway.WaitForAttributeAsync` → the existing `CommunicationService.RouteToWaitForAttributeAsync` cross-site route. Additive `RouteToWaitForAttributeRequest.RequireGoodQuality` (honored by the site handler) makes quality-gated waits route too. **Predicate-form waits stay unsupported** (an in-process lambda can't be routed) and throw a labelled `ScriptSandboxException`. Tests: sandbox accessor routing (CentralUI), site-handler quality-flag threading (SiteRuntime). |
| 15 | BrowseNext final-page signal not surfaced | Already surfaced (M7 browse work): `RealOpcUaClient` sets `Truncated=false`/`ContinuationToken=null` on the last page; `BrowseNodeResult` carries both; `TreeRow.razor` renders "Load more" only when a continuation token remains — no wasted BrowseNext. |
| 16 | StubOpcUaClient throws on browse | Already resolved: `StubOpcUaClient` supports browse + address-space search, covered by `StubOpcUaClientBrowseTests`/`StubOpcUaClientSearchTests`. |
| 20 | Deployment EXPIRED-row purge | Already resolved (PLAN-04): `PendingDeploymentPurgeActor` central singleton (spawned in `AkkaHostedService`) ticks `IDeploymentManagerRepository.PurgeExpiredPendingDeploymentsAsync` every `CommunicationOptions.PendingDeploymentPurgeInterval` (default 1h), options-validated, tested. |
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxInstanceGateway.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxInstanceGateway.cs
index ffb45dfd..fc4d4a9a 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxInstanceGateway.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxInstanceGateway.cs
@@ -1,4 +1,5 @@
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
+using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Communication;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
@@ -74,4 +75,22 @@ public sealed class SandboxInstanceGateway : ISandboxInstanceGateway
$"CallScript(\"{canonicalScriptName}\") on bound instance '{_instanceUniqueName}' failed: {response.ErrorMessage}");
return response.ReturnValue;
}
+
+ ///
+ public async Task WaitForAttributeAsync(
+ string canonicalName, string? targetValueEncoded, TimeSpan timeout, bool requireGoodQuality, CancellationToken ct)
+ {
+ // Reuse the inbound-API cross-site wait route (RouteToWaitForAttributeAsync) — the
+ // same real event-driven waiter Route.To().WaitForAttribute() uses. ParentExecutionId
+ // is null for the sandbox path (no inbound execution to link).
+ var request = new RouteToWaitForAttributeRequest(
+ Guid.NewGuid().ToString(), _instanceUniqueName, canonicalName,
+ targetValueEncoded, timeout, DateTimeOffset.UtcNow,
+ ParentExecutionId: null, RequireGoodQuality: requireGoodQuality);
+ var response = await _comms.RouteToWaitForAttributeAsync(_siteId, request, _runToken);
+ if (!response.Success)
+ throw new ScriptSandboxException(
+ $"WaitForAttribute(\"{canonicalName}\") on bound instance '{_instanceUniqueName}' failed: {response.ErrorMessage}");
+ return new WaitResult(response.Matched, response.Value, response.Quality, response.TimedOut);
+ }
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs
index a329e1c0..1e8f40c3 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs
@@ -114,6 +114,21 @@ public interface ISandboxInstanceGateway
/// The script result, or null if none.
Task