using System.Diagnostics; using System.Text.Json; using Akka.Actor; using Microsoft.Extensions.Logging; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.InboundAPI; /// /// C3/N3: a routed call failed because the target site never answered — the routed /// Ask expired. Unreachability is typed at the Ask boundary from /// (the communication layer's own signal: an /// unreachable/contactless site has its enveloped message dropped so the Ask times /// out caller-side) — it is never inferred from an error-message substring. /// Derives from so existing callers that /// catch the base type are unaffected, while /// catches this more specific type to surface the spec's SITE_UNREACHABLE /// error code instead of a generic SCRIPT_ERROR. A Success=false /// response means the site answered (reachable by definition) and stays a /// plain . /// public sealed class SiteUnreachableException : InvalidOperationException { /// Initializes a new with the given message. /// The routing-level failure message that indicated the site was unreachable. public SiteUnreachableException(string message) : base(message) { } /// Initializes a new wrapping the underlying Ask-timeout. /// The routing-level failure message. /// The underlying from the routed Ask. public SiteUnreachableException(string message, Exception innerException) : base(message, innerException) { } } /// /// Arch-review F2: a routed call whose target site is outside the caller's site /// scope. Raised by RouteTarget.ResolveSiteAsync — the single choke point every routed /// operation passes through — so it is thrown before any site traffic: nothing is sent, /// nothing is read, nothing is written. /// /// /// The scope exists for the Flow Debugger's method-entry Live run, where an operator picks a /// method rather than a site: a site-scoped Deployer must not reach outside their sites just /// because the method's script routes there. The ordinary HTTP inbound path attaches no scope /// (an API key is not a site-scoped principal) and is unaffected. /// /// /// /// Derives from so existing callers catching the base /// type are unaffected, while catches this more specific /// type to surface the SITE_SCOPE_DENIED error code instead of a generic /// SCRIPT_ERROR. Deliberately NOT the ManagementService's SiteScopeViolationException /// — InboundAPI cannot reference ManagementService; the runner converts the code back. /// /// public sealed class RouteSiteScopeException : InvalidOperationException { /// Initializes a new with the given message. /// The scope-denial message naming the instance and its site. public RouteSiteScopeException(string message) : base(message) { } } /// /// Route.To() helper for cross-site calls from inbound API scripts. /// Resolves instance to site, routes via , blocks until /// response or timeout. Site unreachable returns error (no store-and-forward). /// /// The helper carries the executing method's /// (the method-level timeout). Routed calls inherit that deadline by default, so a /// natural script — Route.To("inst").Call("doWork", p) — is timeout-bounded /// without the script having to thread a token explicitly. /// public class RouteHelper { private readonly IInstanceLocator _instanceLocator; private readonly IInstanceRouter _instanceRouter; private readonly CancellationToken _deadlineToken; private readonly CancellationToken _requestAbortedToken; private readonly Guid? _parentExecutionId; private readonly ICentralAuditWriter? _auditWriter; private readonly INodeIdentityProvider? _nodeIdentity; private readonly bool _trace; private readonly Action? _traceSink; private readonly IReadOnlySet? _permittedSiteIdentifiers; private readonly bool _captureBodies; private readonly ILogger? _logger; /// /// Initializes a new with no deadline token and no parent execution id. /// DI (AddScoped<RouteHelper>()) resolves the two optional audit collaborators when the /// host registers them; a bare new RouteHelper(locator, router) writes no audit rows. /// /// Service to resolve the site id for a given instance code. /// Service to route cross-site calls to the resolved site. /// Flow Debugger (design 2026-09-05 §5.1): central-direct-write audit writer for the always-on RouteCall row; null = no row. /// Stamps SourceNode (central-a/central-b) on the RouteCall row; null = NULL. /// Arch-review F8: logs a Warning when the best-effort RouteCall audit write faults; null = silent. public RouteHelper( IInstanceLocator instanceLocator, IInstanceRouter instanceRouter, ICentralAuditWriter? auditWriter = null, INodeIdentityProvider? nodeIdentity = null, ILogger? logger = null) : this(instanceLocator, instanceRouter, CancellationToken.None, CancellationToken.None, parentExecutionId: null, auditWriter, nodeIdentity, trace: false, traceSink: null, permittedSiteIdentifiers: null, captureBodies: true, logger) { } private RouteHelper( IInstanceLocator instanceLocator, IInstanceRouter instanceRouter, CancellationToken deadlineToken, CancellationToken requestAbortedToken, Guid? parentExecutionId, ICentralAuditWriter? auditWriter, INodeIdentityProvider? nodeIdentity, bool trace, Action? traceSink, IReadOnlySet? permittedSiteIdentifiers, bool captureBodies, ILogger? logger) { _permittedSiteIdentifiers = permittedSiteIdentifiers; _instanceLocator = instanceLocator; _instanceRouter = instanceRouter; _deadlineToken = deadlineToken; _requestAbortedToken = requestAbortedToken; _parentExecutionId = parentExecutionId; _auditWriter = auditWriter; _nodeIdentity = nodeIdentity; _trace = trace; _traceSink = traceSink; _captureBodies = captureBodies; _logger = logger; } /// /// Returns a whose routed calls inherit /// (the executing method's timeout) by default. /// calls this when it builds the script /// context so the method timeout actually covers routed calls, as the design doc /// requires. /// /// The executing method's timeout cancellation token to inherit for routed calls. /// A new inheriting the given deadline token. public RouteHelper WithDeadline(CancellationToken deadlineToken) => new(_instanceLocator, _instanceRouter, deadlineToken, _requestAbortedToken, _parentExecutionId, _auditWriter, _nodeIdentity, _trace, _traceSink, _permittedSiteIdentifiers, _captureBodies, _logger); /// /// Returns a carrying the raw request-abort /// token (a client disconnect) separately from the method deadline. Most /// routed calls remain bounded by the method deadline (which already incorporates the /// abort), but uses this token so its wait /// is bounded by the WAIT timeout — not the generic method deadline (spec §6) — while /// still being cancelled by a client disconnect. /// /// The raw client-disconnect token, independent of the method timeout. /// A new carrying the given request-abort token. public RouteHelper WithRequestAborted(CancellationToken requestAbortedToken) => new(_instanceLocator, _instanceRouter, _deadlineToken, requestAbortedToken, _parentExecutionId, _auditWriter, _nodeIdentity, _trace, _traceSink, _permittedSiteIdentifiers, _captureBodies, _logger); /// /// Audit Log (ParentExecutionId): returns a whose /// routed requests carry /// as . /// For an inbound API request this is the inbound request's own per-request /// execution id, so the routed site script records the inbound request as its /// parent. calls this when it builds the /// script context. /// /// The inbound request's execution id to stamp as the spawning parent on routed calls, or null for non-routed runs. /// A new carrying the given parent execution id. public RouteHelper WithParentExecutionId(Guid? parentExecutionId) => new(_instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, parentExecutionId, _auditWriter, _nodeIdentity, _trace, _traceSink, _permittedSiteIdentifiers, _captureBodies, _logger); /// /// Flow Debugger (design 2026-09-05 §5.2): returns a whose routed /// calls and writes carry Trace = , so the site traces the /// spawned run. The always-on RouteCall row is written regardless of this flag. /// /// True to trace the routed site runs. /// A new carrying the flag. public RouteHelper WithTrace(bool trace) => new(_instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, _parentExecutionId, _auditWriter, _nodeIdentity, trace, _traceSink, _permittedSiteIdentifiers, _captureBodies, _logger); /// /// Flow Debugger (design 2026-09-05 §7.1): returns a that also /// hands every RouteCall row AND every inline site trace event on the routed reply /// to , so a Live run can render without a round-trip to the /// audit store. Null clears the sink. /// /// Receives the audit events in emission order; null = none. /// A new carrying the sink. public RouteHelper WithTraceSink(Action? traceSink) => new(_instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, _parentExecutionId, _auditWriter, _nodeIdentity, _trace, traceSink, _permittedSiteIdentifiers, _captureBodies, _logger); /// /// Flow Debugger outbound calls (plan 2026-09-06, D5/D7): the trace sink this helper carries, /// so can hand the SAME delegate to the script's /// and a Live run's central Database.* rows land /// in the same collector as its RouteCall rows. Null when no sink was attached. /// public Action? TraceSink => _traceSink; /// /// Arch-review F2: returns a that refuses a routed call /// whose target site is not in — site /// identifiers (e.g. site-a), the values returns, /// NOT the numeric ids an AuthenticatedUser.PermittedSiteIds carries. Matching is /// whatever comparer the supplied set uses, so pass an ordinal-ignore-case set. /// /// /// null = unscoped, the default and the state of every ordinary inbound HTTP request. /// An EMPTY set is not the same thing: it permits nothing, which is the correct outcome for /// a site-scoped user whose sites no longer exist. /// /// /// /// The Flow Debugger's method-entry Live run sets this from the operator's site-scoped /// Deployer role, because the operator picks a method — the site is only known once /// the script routes, so the check cannot happen up front the way it does for a value-change /// run's named instance. /// /// /// The site identifiers routed calls may reach; null = unscoped. /// A new carrying the scope. public RouteHelper WithSiteScope(IReadOnlySet? permittedSiteIdentifiers) => new(_instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, _parentExecutionId, _auditWriter, _nodeIdentity, _trace, _traceSink, permittedSiteIdentifiers, _captureBodies, _logger); /// /// Arch-review F11: returns a whose always-on RouteCall row /// carries no request/response summary when is false. /// sets this from the executing method's /// AuditLogOptions.PerTargetOverrides[method.Name].SkipBodyCapture — the same override /// AuditWriteMiddleware honours for the inbound row — so an operator who suppressed a /// method's body from the audit log doesn't get it back via the routed call's arguments /// (which are derived from that same body). Default true (unchanged behaviour). /// /// False to omit the request/response summary on the RouteCall row. /// A new carrying the flag. public RouteHelper WithBodyCapture(bool captureBodies) => new(_instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, _parentExecutionId, _auditWriter, _nodeIdentity, _trace, _traceSink, _permittedSiteIdentifiers, captureBodies, _logger); /// /// Creates a route target for the specified instance. /// /// The unique code of the instance to route calls to. /// A bound to the specified instance. public RouteTarget To(string instanceCode) { return new RouteTarget( instanceCode, _instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, _parentExecutionId, _auditWriter, _nodeIdentity, _trace, _traceSink, _permittedSiteIdentifiers, _captureBodies, _logger); } } /// /// Represents a route target (an instance) for cross-site calls. /// public class RouteTarget { private readonly string _instanceCode; private readonly IInstanceLocator _instanceLocator; private readonly IInstanceRouter _instanceRouter; private readonly CancellationToken _deadlineToken; private readonly CancellationToken _requestAbortedToken; private readonly Guid? _parentExecutionId; private readonly ICentralAuditWriter? _auditWriter; private readonly INodeIdentityProvider? _nodeIdentity; private readonly bool _trace; private readonly Action? _traceSink; private readonly IReadOnlySet? _permittedSiteIdentifiers; private readonly bool _captureBodies; private readonly ILogger? _logger; /// /// Initializes a new for the specified instance. /// /// The unique code of the target instance. /// Service to resolve the site id for the instance. /// Service to route cross-site calls. /// Cancellation token representing the method-level deadline. /// Raw client-disconnect token, independent of the method timeout. /// Optional parent execution id for audit correlation on routed calls. /// Central-direct-write audit writer for the always-on RouteCall row; null = no row. /// Stamps SourceNode on the RouteCall row; null = NULL. /// True to trace the routed site run. /// Receives the emitted audit events in order; null = none. /// Arch-review F2: site identifiers this call may reach; null = unscoped. /// Arch-review F11: false omits the request/response summary on the RouteCall row. /// Arch-review F8: logs a Warning when the best-effort RouteCall audit write faults; null = silent. internal RouteTarget( string instanceCode, IInstanceLocator instanceLocator, IInstanceRouter instanceRouter, CancellationToken deadlineToken, CancellationToken requestAbortedToken, Guid? parentExecutionId, ICentralAuditWriter? auditWriter = null, INodeIdentityProvider? nodeIdentity = null, bool trace = false, Action? traceSink = null, IReadOnlySet? permittedSiteIdentifiers = null, bool captureBodies = true, ILogger? logger = null) { _permittedSiteIdentifiers = permittedSiteIdentifiers; _instanceCode = instanceCode; _instanceLocator = instanceLocator; _instanceRouter = instanceRouter; _deadlineToken = deadlineToken; _requestAbortedToken = requestAbortedToken; _parentExecutionId = parentExecutionId; _auditWriter = auditWriter; _nodeIdentity = nodeIdentity; _trace = trace; _traceSink = traceSink; _captureBodies = captureBodies; _logger = logger; } /// /// Calls a script on the remote instance. Synchronous from API caller's /// perspective. may be a dictionary or an /// anonymous object (new { name = "Bob" }) — see . /// /// When is not supplied the /// routed call inherits the executing method's timeout, so the call is bounded by /// the method-level deadline with no token argument. /// /// Name of the script to call on the remote instance. /// Optional parameters passed to the script; may be a dictionary or anonymous object. /// Optional cancellation token; defaults to the method deadline when not supplied. /// A task that resolves to the remote script's return value, or null. public async Task Call( string scriptName, object? parameters = null, CancellationToken cancellationToken = default) { var token = Effective(cancellationToken); var siteId = await ResolveSiteAsync(token); var correlationId = Guid.NewGuid().ToString(); // Audit Log (ParentExecutionId): stamp the spawning execution's id // (the inbound API request's ExecutionId) so the routed site script // records this call's parent. CorrelationId above is a separate concern // — the per-operation lifecycle id, freshly minted per routed call. var normalized = ScriptArgs.Normalize(parameters); var request = new RouteToCallRequest( correlationId, _instanceCode, scriptName, normalized, DateTimeOffset.UtcNow, _parentExecutionId, // Flow Debugger (design 2026-09-05 §5.2): the trace flag rides beside the parent id. Trace: _trace); // Flow Debugger (design §5.1): the ALWAYS-ON RouteCall row — one per routed call, // written whether or not the run is traced (a writer must be present). var startedUtc = DateTime.UtcNow; var sw = Stopwatch.StartNew(); RouteToCallResponse response; try { response = await RouteOrUnreachable(_instanceRouter.RouteToCallAsync(siteId, request, token)); } catch (Exception ex) { EmitRouteCall(scriptName, normalized, startedUtc, sw.ElapsedMilliseconds, success: false, returnValue: null, error: ex.Message, childExecutionId: null, traceEvents: null); throw; } EmitRouteCall(scriptName, normalized, startedUtc, sw.ElapsedMilliseconds, response.Success, response.ReturnValue, response.ErrorMessage, response.ChildExecutionId, response.TraceEvents); if (!response.Success) { throw RoutingFailure( response.ErrorMessage ?? "Remote script call failed"); } return response.ReturnValue; } /// /// Flow Debugger (design 2026-09-05 §5.1/§7.1): builds the ScriptTrace.RouteCall row, /// writes it best-effort through the central audit writer (never throws into the script), /// and hands it — followed by the site's inline trace events — to the trace sink. /// private void EmitRouteCall( string scriptName, IReadOnlyDictionary? args, DateTime startedUtc, long elapsedMs, bool success, object? returnValue, string? error, Guid? childExecutionId, IReadOnlyList? traceEvents) { if (_auditWriter is null && _traceSink is null) return; AuditEvent evt; try { evt = ScadaBridgeAuditEventFactory.Create( AuditChannel.ScriptTrace, AuditKind.RouteCall, success ? AuditStatus.Delivered : AuditStatus.Failed, occurredAtUtc: DateTime.SpecifyKind(startedUtc, DateTimeKind.Utc), actor: "inbound-api", target: $"{_instanceCode}.{scriptName}", sourceNode: _nodeIdentity?.NodeName, executionId: _parentExecutionId, parentExecutionId: null, durationMs: (int)Math.Min(elapsedMs, int.MaxValue), errorMessage: error, // Arch-review F11: a method with SkipBodyCapture set must not leak the routed // call's arguments (derived from the same request body) or its return value. requestSummary: _captureBodies ? TraceJson(args) : null, responseSummary: _captureBodies ? TraceJson(returnValue) : null, extra: childExecutionId is null ? null : new ScriptTraceExtra { ChildExecutionId = childExecutionId }.ToJson()); } catch (Exception) { return; } if (_auditWriter is not null) { try { var t = _auditWriter.WriteAsync(evt, CancellationToken.None); if (!t.IsCompletedSuccessfully) { _ = t.ContinueWith( x => _logger?.LogWarning(x.Exception, "Flow trace RouteCall write failed for {Target}", $"{_instanceCode}.{scriptName}"), CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } } catch (Exception) { // Best-effort: an audit failure never fails the routed call. } } if (_traceSink is null) return; try { _traceSink(evt); if (traceEvents is not null) { foreach (var e in traceEvents) _traceSink(e); } } catch (Exception) { // A misbehaving sink must not fail the script either. } } private static string? TraceJson(object? value) { if (value is null) return null; try { return JsonSerializer.Serialize(value, ScriptTraceExtra.JsonOptions); } catch (Exception) { return null; } } /// /// Gets a single attribute value from the remote instance. /// /// Name of the attribute to read. /// Optional cancellation token; defaults to the method deadline. /// A task that resolves to the attribute value, or null if the key is absent. public async Task GetAttribute( string attributeName, CancellationToken cancellationToken = default) { var result = await GetAttributes(new[] { attributeName }, cancellationToken); return result.TryGetValue(attributeName, out var value) ? value : null; } /// /// Gets multiple attribute values from the remote instance (batch read). /// /// Names of the attributes to read. /// Optional cancellation token; defaults to the method deadline. /// A task that resolves to a map of attribute names to their current values. public async Task> GetAttributes( IEnumerable attributeNames, CancellationToken cancellationToken = default) { var token = Effective(cancellationToken); var siteId = await ResolveSiteAsync(token); var correlationId = Guid.NewGuid().ToString(); // Audit Log (ParentExecutionId): mirrors the Call path — stamp the // spawning inbound request's ExecutionId so future site-side audit // emission for routed reads can record this read's parent. Symmetric // with RouteToCallRequest so script authors get the same correlation // across Call / GetAttributes / SetAttributes. var request = new RouteToGetAttributesRequest( correlationId, _instanceCode, attributeNames.ToList(), DateTimeOffset.UtcNow, _parentExecutionId); var response = await RouteOrUnreachable(_instanceRouter.RouteToGetAttributesAsync(siteId, request, token)); if (!response.Success) { throw RoutingFailure( response.ErrorMessage ?? "Remote attribute read failed"); } return response.Values; } /// /// Blocks until a remote instance attribute reaches /// or elapses (spec §6). Value-equality ONLY across the /// wire: the target is canonically encoded via and /// the site evaluates equality — there is no predicate and no quality flag in the /// comparison. /// /// /// Unlike the other routed calls (which inherit the method deadline /// by default), the wait is bounded by — the WAIT /// timeout — NOT the generic method deadline. The SITE enforces /// and returns Matched=false when it elapses; the local token is built from a /// per-wait CTS (the wait timeout plus a small grace), an explicit caller token, and /// the client-disconnect token, but deliberately EXCLUDES the method deadline. So a /// wait longer than the method timeout runs to its full wait timeout, as spec §6 /// requires, while a client disconnect still cancels it. /// /// /// Name of the attribute to wait on. /// Target value the attribute must equal for the wait to match. /// Maximum time to wait for the attribute to reach the target value; this — not the method deadline — bounds the wait. /// Optional explicit cancellation token (a tighter caller-supplied bound); the wait is otherwise bounded by . /// A task that resolves to true if the attribute reached the target value, false if the wait timed out. public async Task WaitForAttribute( string attributeName, object? targetValue, TimeSpan timeout, CancellationToken cancellationToken = default) { // Do NOT impose a local wait-timeout backstop here. The site // enforces the wait `timeout` and returns Matched=false when it elapses, and the // cluster Ask in CommunicationService.RouteToWaitForAttributeAsync already bounds // the round trip by `timeout + IntegrationTimeout` — the authoritative backstop // for a missing site response. A local CTS of `timeout + small grace` (the prior // approach) was TIGHTER than that round-trip budget, so a // slow-but-valid timed-out response could be cancelled into an exception instead // of the spec-mandated `false`. Link ONLY the client-disconnect token and an // explicit caller token — NOT the method deadline — so a client abort still // cancels the wait while the wait timeout itself governs the duration. using var linked = CancellationTokenSource.CreateLinkedTokenSource( _requestAbortedToken, cancellationToken); var token = linked.Token; var siteId = await ResolveSiteAsync(token); // Audit Log (ParentExecutionId): mirrors the Call path — stamp the // spawning inbound request's ExecutionId so future site-side audit // emission for routed waits can record this wait's parent. CorrelationId // is the per-operation lifecycle id, freshly minted per routed wait. var request = new RouteToWaitForAttributeRequest( Guid.NewGuid().ToString(), _instanceCode, attributeName, AttributeValueCodec.Encode(targetValue), timeout, DateTimeOffset.UtcNow, _parentExecutionId); var response = await RouteOrUnreachable(_instanceRouter.RouteToWaitForAttributeAsync(siteId, request, token)); if (!response.Success) { throw RoutingFailure( response.ErrorMessage ?? "Remote attribute wait failed"); } return response.Matched; } /// /// Sets a single attribute value on the remote instance. /// /// Name of the attribute to write. /// Value to set on the attribute. /// Optional cancellation token; defaults to the method deadline. /// A task that represents the asynchronous operation. public async Task SetAttribute( string attributeName, string value, CancellationToken cancellationToken = default) { await SetAttributes( new Dictionary { { attributeName, value } }, cancellationToken); } /// /// Sets multiple attribute values on the remote instance (batch write). /// /// Map of attribute names to values to write. /// Optional cancellation token; defaults to the method deadline. /// A task that represents the asynchronous operation. public async Task SetAttributes( IReadOnlyDictionary attributeValues, CancellationToken cancellationToken = default) { var token = Effective(cancellationToken); var siteId = await ResolveSiteAsync(token); var correlationId = Guid.NewGuid().ToString(); // Audit Log (ParentExecutionId): mirrors the Call path — stamp the // spawning inbound request's ExecutionId so future site-side audit // emission for routed writes can record this write's parent. Symmetric // with RouteToCallRequest so script authors get the same correlation // across Call / GetAttributes / SetAttributes. var request = new RouteToSetAttributesRequest( correlationId, _instanceCode, attributeValues, DateTimeOffset.UtcNow, _parentExecutionId, // Flow Debugger (design §5.3): a traced routed write arms the site's echo stamp. Trace: _trace); var response = await RouteOrUnreachable(_instanceRouter.RouteToSetAttributesAsync(siteId, request, token)); if (!response.Success) { throw RoutingFailure( response.ErrorMessage ?? "Remote attribute write failed"); } } /// /// A routed call with no explicit token inherits the executing /// method's deadline. An explicitly supplied token (for a tighter bound) wins. /// private CancellationToken Effective(CancellationToken explicitToken) => explicitToken.CanBeCanceled ? explicitToken : _deadlineToken; private async Task ResolveSiteAsync(CancellationToken cancellationToken) { var siteId = await _instanceLocator.GetSiteIdForInstanceAsync(_instanceCode, cancellationToken); if (siteId == null) { // A null site id is a plain not-found / no-assigned-site — never // unreachability (nothing was routed), so it stays a generic // InvalidOperationException (SCRIPT_ERROR). throw RoutingFailure( $"Instance '{_instanceCode}' not found or has no assigned site"); } // Arch-review F2: the site scope gate. This method is the single choke point — // Call, GetAttributes, WaitForAttribute and SetAttributes all resolve the site // here first — so refusing at this point guarantees no site traffic of any kind // is emitted for an out-of-scope instance. if (_permittedSiteIdentifiers is not null && !_permittedSiteIdentifiers.Contains(siteId)) { throw new RouteSiteScopeException( $"Access denied: instance '{_instanceCode}' is at site '{siteId}', outside your site scope."); } return siteId; } /// /// N3: awaits a routed Ask and TYPES unreachability. AskTimeoutException means /// the site never answered (no route to the site, site down, partition) — /// the communication layer's own typed signal (CentralCommunicationActor drops /// envelopes for contactless sites so the Ask expires caller-side) — and /// surfaces as SiteUnreachableException so the executor maps it to the spec's /// SITE_UNREACHABLE code. A Success=false RESPONSE means the site answered and /// is therefore reachable; it flows through RoutingFailure as a plain /// InvalidOperationException (SCRIPT_ERROR). /// private static async Task RouteOrUnreachable(Task routedAsk) { try { return await routedAsk; } catch (AskTimeoutException ex) { throw new SiteUnreachableException( "Site did not respond to the routed request within the routing timeout — unreachable or not connected.", ex); } } /// /// C3/N3: a routing-level failure REPORTED BY the site or the locator. The /// remote end answered (or the instance simply has no site), so this is never /// unreachability — always a plain /// (SCRIPT_ERROR at the endpoint). Unreachability is typed at the Ask boundary /// by RouteOrUnreachable (AskTimeoutException → SiteUnreachableException), /// replacing the old message-substring sniff that a harmless rewording in the /// communication layer could silently defeat. /// private static InvalidOperationException RoutingFailure(string message) => new(message); }