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 CallScriptAsync( string canonicalScriptName, IReadOnlyDictionary? parameters, CancellationToken ct); + + /// + /// Blocks until the named attribute reaches a target value (value-equality, + /// codec-encoded), optionally quality-gated, or the timeout elapses — routed to the + /// bound deployed instance cross-site. Value-equality only: a script-side predicate + /// cannot be routed, so the sandbox predicate overloads stay unsupported. + /// + /// The (already scope-resolved) canonical attribute name. + /// The AttributeValueCodec-encoded target value, or null for "any change". + /// Maximum time to wait. + /// When true, only a Good-quality value satisfies the wait. + /// Cancellation token. + /// The wait outcome (matched flag, matched value, quality, timed-out flag). + Task WaitForAttributeAsync( + string canonicalName, string? targetValueEncoded, TimeSpan timeout, bool requireGoodQuality, CancellationToken ct); } /// @@ -212,6 +227,26 @@ public class SandboxInstanceContext "bind one in Test Run to call sibling scripts."); return _gateway.CallScriptAsync(scriptName, ScriptArgs.Normalize(parameters), CancellationToken.None); } + + /// + /// Routes a value-equality attribute wait to the bound deployed instance cross-site. + /// Needs a bound instance (the site owns the event-driven waiter); throws when unbound. + /// + /// The (already scope-resolved) canonical attribute name. + /// The codec-encoded target value, or null for "any change". + /// Maximum time to wait. + /// When true, only a Good-quality value satisfies the wait. + /// The wait outcome. + public Task WaitForAttribute( + string canonicalName, string? targetValueEncoded, TimeSpan timeout, bool requireGoodQuality) + { + if (_gateway == null) + throw new ScriptSandboxException( + $"WaitForAttribute(\"{canonicalName}\") needs a deployed instance — " + + "bind one in Test Run to exercise the event-driven attribute waiter."); + return _gateway.WaitForAttributeAsync( + canonicalName, targetValueEncoded, timeout, requireGoodQuality, CancellationToken.None); + } } /// @@ -342,20 +377,23 @@ public class SandboxAttributeAccessor => throw NotInSandbox(nameof(WriteBatchAndWaitAsync)); /// - /// Sandbox stand-in for AttributeAccessor.WaitAsync (value-equality form); - /// see . + /// Value-equality attribute wait — routed to the bound deployed instance cross-site + /// (the same real event-driven waiter a site uses). Needs a bound instance; throws + /// when the Test Run has no instance bound. /// /// The attribute key to observe. - /// The value to wait for. + /// The value to wait for (codec-normalized), or null for "any change". /// The maximum time to wait. /// Whether the attribute must also have good quality to satisfy the wait. - /// Never returns; always throws in the sandbox. - public Task WaitAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false) - => throw NotInSandbox(nameof(WaitAsync)); + /// True when the attribute reached the target within the timeout; false on timeout. + public async Task WaitAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false) + => (await _ctx.WaitForAttribute( + Resolve(key), AttributeValueCodec.Encode(targetValue), timeout, requireGoodQuality)).Matched; /// - /// Sandbox stand-in for AttributeAccessor.WaitAsync (predicate form); - /// see . + /// Predicate-form attribute wait. Unsupported in Test Run because a script-side + /// predicate (an in-process lambda) cannot be evaluated on the remote site — only the + /// value-equality overloads route. Throws a clearly-labelled . /// /// The attribute key to observe. /// The predicate the attribute value must satisfy. @@ -363,23 +401,25 @@ public class SandboxAttributeAccessor /// Whether the attribute must also have good quality to satisfy the wait. /// Never returns; always throws in the sandbox. public Task WaitAsync(string key, Func predicate, TimeSpan timeout, bool requireGoodQuality = false) - => throw NotInSandbox(nameof(WaitAsync)); + => throw PredicateWaitNotRoutable(nameof(WaitAsync)); /// - /// Sandbox stand-in for AttributeAccessor.WaitForAsync (value-equality form); - /// see . + /// Value-equality attribute wait returning the full — routed + /// to the bound deployed instance cross-site. Needs a bound instance; throws + /// when the Test Run has no instance bound. /// /// The attribute key to observe. - /// The value to wait for. + /// The value to wait for (codec-normalized), or null for "any change". /// The maximum time to wait. /// Whether the attribute must also have good quality to satisfy the wait. - /// Never returns; always throws in the sandbox. + /// The wait outcome (matched flag, matched value, quality, timed-out flag). public Task WaitForAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false) - => throw NotInSandbox(nameof(WaitForAsync)); + => _ctx.WaitForAttribute(Resolve(key), AttributeValueCodec.Encode(targetValue), timeout, requireGoodQuality); /// - /// Sandbox stand-in for AttributeAccessor.WaitForAsync (predicate form); - /// see . + /// Predicate-form attribute wait returning the full . + /// Unsupported in Test Run for the same reason as the predicate + /// overload — an in-process predicate cannot be routed to the remote site. /// /// The attribute key to observe. /// The predicate the attribute value must satisfy. @@ -387,12 +427,18 @@ public class SandboxAttributeAccessor /// Whether the attribute must also have good quality to satisfy the wait. /// Never returns; always throws in the sandbox. public Task WaitForAsync(string key, Func predicate, TimeSpan timeout, bool requireGoodQuality = false) - => throw NotInSandbox(nameof(WaitForAsync)); + => throw PredicateWaitNotRoutable(nameof(WaitForAsync)); private static ScriptSandboxException NotInSandbox(string member) => new($"{member}(...) drives live device tags and the site's event-driven " + "attribute waiter, which aren't available in the central Test Run sandbox — " + "deploy to a site to exercise batch-write/wait handshakes."); + + private static ScriptSandboxException PredicateWaitNotRoutable(string member) => + new($"{member}(...) with a predicate can't run in Test Run — a script-side " + + "predicate is an in-process lambda that cannot be evaluated on the remote " + + "site. Use the value-equality overload (which routes to the bound instance), " + + "or deploy to a site to exercise predicate waits."); } /// diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/InboundApi/RouteToInstanceRequest.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/InboundApi/RouteToInstanceRequest.cs index 816c17d3..9a880561 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/InboundApi/RouteToInstanceRequest.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/InboundApi/RouteToInstanceRequest.cs @@ -87,9 +87,10 @@ public record RouteToSetAttributesResponse( /// /// Request to block until a remote instance attribute reaches a target value /// (spec §6 — Route.To("inst").WaitForAttribute(name, targetValue, timeout)). -/// Value-equality ONLY across the wire: carries the -/// canonical AttributeValueCodec-encoded target; there is no predicate and no -/// quality flag in the comparison. The site evaluates equality and either matches or +/// Value-equality across the wire: carries the +/// canonical AttributeValueCodec-encoded target; there is no predicate (a +/// cross-process lambda cannot be routed). optionally +/// gates the match on Good quality. The site evaluates the match and either matches or /// times out. /// /// @@ -99,6 +100,12 @@ public record RouteToSetAttributesResponse( /// so the inbound→site execution-tree link survives the wait path. Additive trailing /// member — null for the Central UI sandbox path or for callers built before the field existed. /// +/// +/// Quality-gated ("Good"-only) mode (spec §4.2): when true, a value reaching the target at +/// Bad/Uncertain quality is NOT a match — the site holds the wait until the value satisfies +/// the target at Good quality (or times out). Additive trailing member; defaults to +/// (quality-agnostic) so older callers and the wire default are unchanged. +/// public record RouteToWaitForAttributeRequest( string CorrelationId, string InstanceUniqueName, @@ -106,7 +113,8 @@ public record RouteToWaitForAttributeRequest( string? TargetValueEncoded, TimeSpan Timeout, DateTimeOffset Timestamp, - Guid? ParentExecutionId = null); + Guid? ParentExecutionId = null, + bool RequireGoodQuality = false); /// /// Response from a remote attribute wait. / diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs index 13c36e4f..d5aa014c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -1756,10 +1756,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers /// Spec §6 (WD-2b): unpacks a routed /// (inbound-API Route.To().WaitForAttribute()) into the deployed /// Instance Actor's site-local and relays - /// the result back. Value-equality only across the wire — the predicate is null - /// and RequireGoodQuality is left at its default. The Ask is bounded by the - /// wait timeout plus slack (NOT a fixed 30s), since the wait legitimately blocks - /// for up to . + /// the result back. Value-equality across the wire — the predicate is null (a + /// cross-process lambda cannot be routed), but RequireGoodQuality is honored + /// from . The Ask is + /// bounded by the wait timeout plus slack (NOT a fixed 30s), since the wait + /// legitimately blocks for up to . /// private void RouteInboundApiWaitForAttribute(RouteToWaitForAttributeRequest request) { @@ -1773,10 +1774,12 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers } var sender = Sender; - // Routed waits are value-equality only (predicate null); RequireGoodQuality left at default. + // Routed waits are value-equality (predicate null — a cross-process lambda + // cannot be routed); RequireGoodQuality is honored from the request. var inner = new WaitForAttributeRequest( request.CorrelationId, request.InstanceUniqueName, request.AttributeName, - request.TargetValueEncoded, null, request.Timeout, DateTimeOffset.UtcNow); + request.TargetValueEncoded, null, request.Timeout, DateTimeOffset.UtcNow, + request.RequireGoodQuality); // Ask bounded by the WAIT timeout + slack — NOT a fixed 30s (the wait legitimately blocks up to request.Timeout). instanceActor.Ask(inner, request.Timeout + TimeSpan.FromSeconds(5)) diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/SandboxWaitForAttributeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/SandboxWaitForAttributeTests.cs new file mode 100644 index 00000000..8dabb959 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/SandboxWaitForAttributeTests.cs @@ -0,0 +1,80 @@ +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis; +using ZB.MOM.WW.ScadaBridge.Commons.Types; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.ScriptAnalysis; + +/// +/// Deferred-work #14 (full fidelity): the Test-Run sandbox routes value-equality +/// WaitAsync/WaitForAsync to the bound instance via +/// ; predicate overloads and unbound runs still +/// throw a clearly-labelled . +/// +public class SandboxWaitForAttributeTests +{ + private static SandboxAttributeAccessor BoundAccessor(ISandboxInstanceGateway gateway) => + new(new SandboxInstanceContext(gateway), prefix: string.Empty); + + [Fact] + public async Task WaitAsync_ValueForm_RoutesThroughGateway_AndReturnsMatched() + { + var gateway = Substitute.For(); + gateway.WaitForAttributeAsync("Flag", Arg.Any(), Arg.Any(), false, Arg.Any()) + .Returns(new WaitResult(true, true, "Good", false)); + var accessor = BoundAccessor(gateway); + + var matched = await accessor.WaitAsync("Flag", true, TimeSpan.FromSeconds(5)); + + Assert.True(matched); + // The target is codec-encoded and the resolved (root-scope) name is passed through. + await gateway.Received(1).WaitForAttributeAsync( + "Flag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(5), false, Arg.Any()); + } + + [Fact] + public async Task WaitForAsync_ValueForm_PassesRequireGoodQuality_AndReturnsFullResult() + { + var gateway = Substitute.For(); + gateway.WaitForAttributeAsync("Done", Arg.Any(), Arg.Any(), true, Arg.Any()) + .Returns(new WaitResult(true, 7, "Good", false)); + var accessor = BoundAccessor(gateway); + + var result = await accessor.WaitForAsync("Done", 7, TimeSpan.FromSeconds(3), requireGoodQuality: true); + + Assert.True(result.Matched); + Assert.Equal(7, result.Value); + Assert.Equal("Good", result.Quality); + await gateway.Received(1).WaitForAttributeAsync( + "Done", AttributeValueCodec.Encode(7), TimeSpan.FromSeconds(3), true, Arg.Any()); + } + + [Fact] + public async Task WaitAsync_PredicateForm_Throws() + { + var accessor = BoundAccessor(Substitute.For()); + + var ex = await Assert.ThrowsAsync( + () => accessor.WaitAsync("Done", v => v != null, TimeSpan.FromSeconds(1))); + Assert.Contains("predicate", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task WaitForAsync_PredicateForm_Throws() + { + var accessor = BoundAccessor(Substitute.For()); + + await Assert.ThrowsAsync( + () => accessor.WaitForAsync("Done", v => v != null, TimeSpan.FromSeconds(1))); + } + + [Fact] + public async Task WaitAsync_ValueForm_UnboundInstance_Throws() + { + // No gateway bound → the sandbox has no instance to route to. + var accessor = new SandboxAttributeAccessor(new SandboxInstanceContext(), prefix: string.Empty); + + var ex = await Assert.ThrowsAsync( + () => accessor.WaitAsync("Flag", true, TimeSpan.FromSeconds(1))); + Assert.Contains("deployed instance", ex.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs index 99a186b6..2f848369 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs @@ -551,6 +551,35 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.Equal("Good", response.Quality); } + [Fact] + public async Task RouteInboundApiWaitForAttribute_RequireGoodQuality_IsThreadedToInstanceActor() + { + // #14 (full fidelity): the additive RouteToWaitForAttributeRequest.RequireGoodQuality + // flag must reach the InstanceActor's wait. A static attribute is Good quality, so a + // quality-gated wait whose target equals the current value still matches — proving the + // flag threads through (and does not spuriously reject a Good value). + var actor = CreateDeploymentManager(); + await Task.Delay(500); + + actor.Tell(new DeployInstanceCommand( + "dep-wait-gq", "WaitPumpGq", "sha256:wait-gq", + MakeConfigJson("WaitPumpGq"), "admin", DateTimeOffset.UtcNow)); + ExpectMsg(TimeSpan.FromSeconds(5)); + await Task.Delay(1000); + + var encodedTarget = AttributeValueCodec.Encode("42"); + actor.Tell(new RouteToWaitForAttributeRequest( + "wait-corr-gq", "WaitPumpGq", "TestAttr", encodedTarget, + TimeSpan.FromSeconds(5), DateTimeOffset.UtcNow, + ParentExecutionId: null, RequireGoodQuality: true)); + + var response = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.Equal("wait-corr-gq", response.CorrelationId); + Assert.True(response.Success, $"Routed quality-gated wait failed: {response.ErrorMessage}"); + Assert.True(response.Matched, "A Good-quality attribute at target must satisfy a RequireGoodQuality wait."); + Assert.Equal("Good", response.Quality); + } + [Fact] public async Task RouteInboundApiWaitForAttribute_UnknownInstance_RepliesNotFound() {