Files
ScadaBridge/docs/plans/2026-06-17-waitfor-deferred-items.md
T

17 KiB

WaitAsync Deferred Optional Items — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (subagent-driven) to implement this plan task-by-task.

Goal: Implement the three items deferred from the WaitAsync spec (docs/plans/2026-06-17-waitfor-attribute-change-helper-spec.md): §3 WaitForAsync/WaitResult richer overload, §4.2 quality-gated ("Good"-only) matching, and §6 inbound/routed Route.To(...).WaitForAttribute variant.

Architecture: Builds on the shipped core (b89d69a04e97f4). Two of the items (§3, §4.2) are site-local enrichments of the existing Attributes script surface + InstanceActor waiter; no new actor protocol shapes beyond an additive RequireGoodQuality field. The third (§6) mirrors the existing Route.To(...).GetAttributes cross-cluster path end-to-end (RouteTargetIInstanceRouterCommunicationServiceSiteCommunicationActorDeploymentManagerActorInstanceActor), value-equality only across the wire, with the cluster Ask bounded by the wait timeout rather than the generic integration timeout.

Tech Stack: C#/.NET 10, Akka.NET 1.5, xUnit + Akka.TestKit + NSubstitute.

Branch/worktree: waitfor-attr-helper at /Users/dohertj2/Desktop/ScadaBridge/.claude/worktrees/waitfor-attr-helper (off local main; carries the core feature). Implementers do NOT create worktrees, commit pathspec form (git commit -m "…" -- <paths>), do NOT push, do NOT touch main. Targeted builds/tests per task; full-solution build only in WD-3.


Naming / shared shapes

  • New script return type WaitResult (Commons): public readonly record struct WaitResult(bool Matched, object? Value, string? Quality, bool TimedOut);
  • WaitForAttributeRequest gains a trailing additive field bool RequireGoodQuality = false (site-local request). RequireGoodQuality semantics: a match requires the value test to pass and string.Equals(quality, "Good", StringComparison.Ordinal).
  • Routed contract (value-equality only, no predicate, no quality flag across the wire — §6 says value-equality only): RouteToWaitForAttributeRequest / RouteToWaitForAttributeResponse (Commons Messages/InboundApi).
  • The WaitForAttributeResponse.Quality field is already string? (null on timeout/error).

Execution waves

  • Wave 1 (parallel, disjoint files): WD-1 ∥ WD-2a. (2 concurrent committers; post-wave HEAD-presence check.)
  • Wave 2: WD-2b (after WD-2a).
  • Wave 3: WD-3 (after WD-1, WD-2a, WD-2b).

WD-1 must add RequireGoodQuality ONLY as a trailing defaulted ctor param of WaitForAttributeRequest, so WD-2b's new WaitForAttributeRequest(...) (built in wave 2) compiles regardless.


Task WD-1: Site-local WaitForAsync + WaitResult + quality-gated mode (§3 + §4.2)

Classification: high-risk (modifies the InstanceActor single-threaded match evaluation + an additive message-contract field) Estimated implement time: ~5 min Parallelizable with: WD-2a

Files:

  • Create: src/ZB.MOM.WW.ScadaBridge.Commons/Types/WaitResult.cs
  • Modify: src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Instance/WaitForAttribute.cs (add trailing bool RequireGoodQuality = false to WaitForAttributeRequest)
  • Modify: src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs (thread RequireGoodQuality into PendingWait + both match sites)
  • Modify: src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs (add WaitAttributeFull returning WaitResult; add requireGoodQuality param)
  • Modify: src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs (add WaitForAsync overloads + requireGoodQuality optional param on WaitAsync)
  • Test: tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorWaitForAttributeTests.cs + tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs

Steps (TDD):

  1. WaitResult — add the readonly record struct above.

  2. WaitForAttributeRequest — add trailing bool RequireGoodQuality = false. Keep the Func<> predicate field as-is. Update the XML-doc.

  3. InstanceActor — add bool RequireGoodQuality to the PendingWait record. At BOTH match sites build the effective match as:

    // fast-path (HandleWaitForAttribute): quality from _attributeQualities.GetValueOrDefault(name, <existing default>)
    // resolve loop (ResolveMatchedWaiters): quality from changed.Quality
    bool QualityOk(string? q) => !requireGoodQuality || string.Equals(q, "Good", StringComparison.Ordinal);
    bool matched = QualityOk(quality) && test(value);   // keep test() inside its existing try/catch
    

    Store RequireGoodQuality on the PendingWait so the resolve loop knows it. Keep the throwing-predicate guard (the QualityOk && test must still be inside the existing try/catch). The fast-path quality-fail when requireGoodQuality is just a non-match → register + schedule timeout as normal (do NOT fast-reply matched).

  4. ScriptRuntimeContext — refactor: a private Task<WaitForAttributeResponse> WaitInternal(name, encoded, predicate, timeout, requireGoodQuality) that does the token-bounded Ask (keep the existing AskTimeoutException → ... handling; on AskTimeout return a synthetic WaitForAttributeResponse(.., Matched:false, TimedOut:true)). Then:

    public async Task<bool> WaitAttribute(string name, string? enc, Func<object?,bool>? pred, TimeSpan t, bool requireGoodQuality = false)
        => (await WaitInternal(name, enc, pred, t, requireGoodQuality)).Matched;
    public async Task<WaitResult> WaitAttributeFull(string name, string? enc, Func<object?,bool>? pred, TimeSpan t, bool requireGoodQuality = false)
    { var r = await WaitInternal(...); return new WaitResult(r.Matched, r.Value, r.Quality, r.TimedOut); }
    

    (Note: WaitAttribute's existing AskTimeoutException → return false must be preserved — fold it into WaitInternal returning a non-matched/timed-out response, OR catch in both. Do NOT catch OperationCanceledException/TaskCanceledException.)

  5. AttributeAccessor — add requireGoodQuality optional param to both existing WaitAsync overloads, and add two WaitForAsync overloads:

    public Task<WaitResult> WaitForAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false)
        => _ctx.WaitAttributeFull(Resolve(key), AttributeValueCodec.Encode(targetValue), null, timeout, requireGoodQuality);
    public Task<WaitResult> WaitForAsync(string key, Func<object?,bool> predicate, TimeSpan timeout, bool requireGoodQuality = false)
        => _ctx.WaitAttributeFull(Resolve(key), null, predicate, timeout, requireGoodQuality);
    

    XML-doc: requireGoodQuality:true ignores Bad/Uncertain-quality transients.

  6. Tests (extend existing files): (a) WaitForAsync returns a populated WaitResult on match (Value+Quality) and on timeout (Matched:false, TimedOut:true). (b) quality-gated: a value reaching target at Bad quality does NOT match when requireGoodQuality:true (stays pending → times out), but DOES match when false; and matches when it reaches target at Good quality. Cover both fast-path (already-at-target-but-Bad) and change-match. (c) scope resolution still applied for WaitForAsync.

  7. Build Commons + SiteRuntime + the SiteRuntime test project; run --filter "FullyQualifiedName~WaitForAttribute|FullyQualifiedName~WaitAsync|FullyQualifiedName~WaitForAsync" and the ~InstanceActor|~ScopeAccessor regression filter. All green.

  8. Commit (pathspec).


Task WD-2a: Routed contract + central path (§6, part 1)

Classification: high-risk (cross-cluster message contract + IInstanceRouter surface) Estimated implement time: ~5 min Parallelizable with: WD-1

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Commons/Messages/InboundApi/RouteToInstanceRequest.cs (add the two records)
  • Modify: src/ZB.MOM.WW.ScadaBridge.InboundAPI/IInstanceRouter.cs (add method)
  • Modify: src/ZB.MOM.WW.ScadaBridge.InboundAPI/CommunicationServiceInstanceRouter.cs (delegate)
  • Modify: src/ZB.MOM.WW.ScadaBridge.InboundAPI/RouteHelper.cs (RouteTarget.WaitForAttribute)
  • Modify: src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs (RouteToWaitForAttributeAsyncwait-timeout-aware Ask)
  • Modify (compile-break fixes — interface gained a member): tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs (BridgingInstanceRouter) and the inline IInstanceRouter double in tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointContentTypeTests.cs
  • Test: tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RouteHelperTests.cs

Steps (TDD):

  1. Commons records (mirror RouteToGetAttributes*, value-equality only):

    public record RouteToWaitForAttributeRequest(
        string CorrelationId, string InstanceUniqueName, string AttributeName,
        string? TargetValueEncoded, TimeSpan Timeout, DateTimeOffset Timestamp,
        Guid? ParentExecutionId = null);
    public record RouteToWaitForAttributeResponse(
        string CorrelationId, bool Matched, object? Value, string? Quality, bool TimedOut,
        bool Success, string? ErrorMessage, DateTimeOffset Timestamp);
    

    (Success/ErrorMessage = routing-level outcome, e.g. instance-not-found; Matched/TimedOut/Value/Quality = wait outcome.)

  2. IInstanceRouter — add Task<RouteToWaitForAttributeResponse> RouteToWaitForAttributeAsync(string siteId, RouteToWaitForAttributeRequest request, CancellationToken cancellationToken);. Update all 3 implementers (prod CommunicationServiceInstanceRouter + the 2 test doubles listed above; the test doubles can return a canned response / throw NotImplemented only if never exercised — prefer a sane canned response).

  3. CommunicationServiceInstanceRouter — delegate to _communicationService.RouteToWaitForAttributeAsync(...).

  4. RouteHelper.RouteTarget — add (mirror GetAttributes, throw on !Success):

    public async Task<bool> WaitForAttribute(string attributeName, object? targetValue, TimeSpan timeout, CancellationToken cancellationToken = default)
    {
        var token = Effective(cancellationToken);
        var siteId = await ResolveSiteAsync(token);
        var request = new RouteToWaitForAttributeRequest(Guid.NewGuid().ToString(), _instanceCode,
            attributeName, AttributeValueCodec.Encode(targetValue), timeout, DateTimeOffset.UtcNow, _parentExecutionId);
        var response = await _instanceRouter.RouteToWaitForAttributeAsync(siteId, request, token);
        if (!response.Success) throw new InvalidOperationException(response.ErrorMessage ?? "Remote attribute wait failed");
        return response.Matched;
    }
    

    (AttributeValueCodec is in Commons.Types — add the using if needed.)

  5. CommunicationService.RouteToWaitForAttributeAsync — mirror RouteToGetAttributesAsync BUT bound the Ask by the wait timeout, not the generic integration timeout:

    var envelope = new SiteEnvelope(siteId, request);
    var askTimeout = request.Timeout + _options.IntegrationTimeout; // slack beyond the wait
    return await GetActor().Ask<RouteToWaitForAttributeResponse>(envelope, askTimeout, cancellationToken);
    
  6. Test (RouteHelperTests): with a substitute IInstanceRouter returning a canned RouteToWaitForAttributeResponse(Matched:true,...), Route.To("x").WaitForAttribute("Flag", true, 30s) returns true; Success:false → throws InvalidOperationException; the encoded target equals AttributeValueCodec.Encode(true).

  7. Build Commons + InboundAPI + Communication + the two affected test projects; run --filter "FullyQualifiedName~RouteHelper" + a build of AuditLog.Tests/InboundAPI.Tests to confirm the interface-addition compiles. Commit (pathspec).


Task WD-2b: Site unpacking + handler (§6, part 2)

Classification: high-risk (actor handler crossing into InstanceActor; Ask-timeout correctness) Estimated implement time: ~4 min Parallelizable with: none blockedBy: WD-2a

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs (add Receive<RouteToWaitForAttributeRequest>(msg => _deploymentManagerProxy.Forward(msg)); next to the other RouteTo forwards ~line 145)
  • Modify: src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs (Receive<RouteToWaitForAttributeRequest>(RouteInboundApiWaitForAttribute); + handler)
  • Test: tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs

Steps (TDD):

  1. SiteCommunicationActor — add the Receive/Forward line.

  2. DeploymentManagerActor.RouteInboundApiWaitForAttribute — mirror RouteInboundApiGetAttributes:

    private void RouteInboundApiWaitForAttribute(RouteToWaitForAttributeRequest request)
    {
        if (!_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor))
        {
            Sender.Tell(new RouteToWaitForAttributeResponse(request.CorrelationId, false, null, null, false,
                false, $"Instance '{request.InstanceUniqueName}' not found on this site.", DateTimeOffset.UtcNow));
            return;
        }
        var sender = Sender;
        var inner = new WaitForAttributeRequest(request.CorrelationId, request.InstanceUniqueName,
            request.AttributeName, request.TargetValueEncoded, null /*predicate*/, request.Timeout,
            DateTimeOffset.UtcNow /*, RequireGoodQuality defaults false */);
        // Ask bounded by the WAIT timeout + slack (NOT a fixed 30s).
        instanceActor.Ask<WaitForAttributeResponse>(inner, request.Timeout + TimeSpan.FromSeconds(5))
            .ContinueWith(t => t.IsCompletedSuccessfully
                ? new RouteToWaitForAttributeResponse(request.CorrelationId, t.Result.Matched, t.Result.Value,
                    t.Result.Quality, t.Result.TimedOut, true, null, DateTimeOffset.UtcNow)
                : new RouteToWaitForAttributeResponse(request.CorrelationId, false, null, null, false, false,
                    t.Exception?.GetBaseException().Message ?? "Attribute wait timed out", DateTimeOffset.UtcNow))
            .PipeTo(sender);
    }
    

    (WaitForAttributeRequest lives in Commons Messages/Instance — add the using. Build with both the trailing-RequireGoodQuality and pre-field signatures in mind; passing 7 positional args + default is fine.)

  3. Test (DeploymentManagerActorTests, mirror the routed get-attributes test): deploy/register an instance whose attribute already equals the target → RouteToWaitForAttributeRequestRouteToWaitForAttributeResponse(Success:true, Matched:true); unknown instance → Success:false.

  4. Build Communication + SiteRuntime + SiteRuntime test project; run --filter "FullyQualifiedName~DeploymentManagerActor". Commit (pathspec).


Task WD-3: Integration — docs + full verification

Classification: standard Estimated implement time: ~4 min Parallelizable with: none blockedBy: WD-1, WD-2a, WD-2b

Files:

  • Modify: docs/plans/2026-06-17-waitfor-attribute-change-helper-spec.md (mark §3 WaitForAsync/WaitResult, §4.2 quality-gated mode, and §6 routed variant as IMPLEMENTED; note Test-Run sandbox parity excluded)
  • Modify: docs/requirements/Component-SiteRuntime.md (script-surface note: Attributes.WaitForAsync + requireGoodQuality) and docs/requirements/Component-InboundAPI.md (Route.To(...).WaitForAttribute) — brief, only if those docs enumerate the script surface
  • (No new component, no migration, no docker config change)

Steps:

  1. Update the spec doc + component docs as above.
  2. Full-solution build: dotnet build ZB.MOM.WW.ScadaBridge.slnx — 0 errors.
  3. Targeted test sweep across everything touched: dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/... --filter "FullyQualifiedName~WaitForAttribute|FullyQualifiedName~WaitAsync|FullyQualifiedName~WaitForAsync|FullyQualifiedName~DeploymentManagerActor", dotnet test tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/... --filter "FullyQualifiedName~RouteHelper", and a build of tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests + tests/ZB.MOM.WW.ScadaBridge.Communication.Tests to confirm no compile/regression from the interface addition.
  4. git diff review; commit (pathspec).

Out of scope (explicit)

  • 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).
  • No docker redeploy (no cluster-runtime config change; additive script surface only).