fix(inbound): authorize+secure Database helper, async/deadline-bound DB, wait-timeout-bound WaitForAttribute

Resolves InboundAPI-026/027/028/029 (+ newly-surfaced -030).

- 026: authorize the scoped Database helper in the design doc; SQL-injection
  protection is parameter binding (values never concatenated); allow writes via
  ExecuteAsync; drop the false 'read-only' claim. Named connections only.
- 027: async ADO.NET end-to-end (no .GetAwaiter().GetResult()); honour the method
  deadline token on ExecuteScalarAsync/ExecuteReaderAsync/ExecuteNonQueryAsync +
  a CommandTimeout backstop derived from the method timeout.
- 028: negative-path tests (null-gateway, deadline cancellation, parameterization)
  + e2e Database + WaitForAttribute cases through the real endpoint.
- 029: WaitForAttribute is bounded by its WAIT timeout (per-wait CTS + client-abort
  + explicit token), NOT the method deadline (spec §6) — a long wait may outlive the
  method timeout; WithRequestAborted threads the raw client-abort token separately.
- 030: Central UI compile-surface mirrors (InboundScriptHost / SandboxInboundScriptHost)
  gained the Database member (drifted since the runtime helper was added) so the
  authorized async API type-checks at the design-time gate.
This commit is contained in:
Joseph Doherty
2026-06-23 22:00:17 -04:00
parent d39089f4ed
commit b3c9014379
11 changed files with 540 additions and 68 deletions
@@ -20,11 +20,52 @@ public class InboundScriptHost
/// </summary>
public RouteHelper Route { get; } = new();
/// <summary>
/// Scoped, parameterized database access. Editor mirror of
/// ZB.MOM.WW.ScadaBridge.InboundAPI.InboundDatabaseHelper (InboundAPI-026/030) — its
/// signatures must match the runtime helper so a script using <c>Database.*</c>
/// type-checks during analysis.
/// </summary>
public DatabaseAccessor Database { get; } = new();
/// <summary>
/// The cancellation token for the operation.
/// </summary>
public System.Threading.CancellationToken CancellationToken { get; }
/// <summary>Editor mirror of ZB.MOM.WW.ScadaBridge.InboundAPI.InboundDatabaseHelper.</summary>
public class DatabaseAccessor
{
/// <summary>First column of the first row as <typeparamref name="T"/> (default if no rows).</summary>
/// <typeparam name="T">The scalar result type.</typeparam>
/// <param name="connectionName">A configured database connection name.</param>
/// <param name="sql">The SQL statement; values supplied via <paramref name="parameters"/>.</param>
/// <param name="parameters">Optional anonymous object of bound parameters.</param>
/// <returns>A task that resolves to the scalar, or default.</returns>
public System.Threading.Tasks.Task<T?> QuerySingleAsync<T>(
string connectionName, string sql, object? parameters = null) =>
System.Threading.Tasks.Task.FromResult<T?>(default);
/// <summary>All rows as case-insensitive column→value dictionaries.</summary>
/// <param name="connectionName">A configured database connection name.</param>
/// <param name="sql">The SQL statement; values supplied via <paramref name="parameters"/>.</param>
/// <param name="parameters">Optional anonymous object of bound parameters.</param>
/// <returns>A task that resolves to the result rows.</returns>
public System.Threading.Tasks.Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
string connectionName, string sql, object? parameters = null) =>
System.Threading.Tasks.Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>(
new List<IReadOnlyDictionary<string, object?>>());
/// <summary>Runs a write statement; returns rows affected.</summary>
/// <param name="connectionName">A configured database connection name.</param>
/// <param name="sql">The SQL statement; values supplied via <paramref name="parameters"/>.</param>
/// <param name="parameters">Optional anonymous object of bound parameters.</param>
/// <returns>A task that resolves to the number of rows affected.</returns>
public System.Threading.Tasks.Task<int> ExecuteAsync(
string connectionName, string sql, object? parameters = null) =>
System.Threading.Tasks.Task.FromResult(0);
}
/// <summary>Editor mirror of ZB.MOM.WW.ScadaBridge.InboundAPI.RouteHelper.</summary>
public class RouteHelper
{
@@ -23,6 +23,48 @@ public class SandboxInboundScriptHost
/// <summary>Gets the route accessor; every call throws <see cref="ScriptSandboxException"/> in a test run.</summary>
public RouteAccessor Route { get; } = new();
/// <summary>Gets the database accessor; every call throws <see cref="ScriptSandboxException"/> in a test run.</summary>
public DatabaseAccessor Database { get; } = new();
/// <summary>
/// Mirror of ZB.MOM.WW.ScadaBridge.InboundAPI.InboundDatabaseHelper — signatures match
/// the runtime helper so the same user code compiles, but every call throws because a
/// central Test Run has no configured database connection to reach (mirrors how
/// <see cref="RouteAccessor"/> throws on cross-site routing).
/// </summary>
public class DatabaseAccessor
{
/// <summary>Always throws <see cref="ScriptSandboxException"/>; database access is unavailable in a Test Run.</summary>
/// <typeparam name="T">The scalar result type.</typeparam>
/// <param name="connectionName">Connection name (included in the exception message).</param>
/// <param name="sql">Unused SQL.</param>
/// <param name="parameters">Unused parameters.</param>
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/>.</returns>
public Task<T?> QuerySingleAsync<T>(string connectionName, string sql, object? parameters = null) =>
throw Unavailable($"Database.QuerySingleAsync(\"{connectionName}\")");
/// <summary>Always throws <see cref="ScriptSandboxException"/>; database access is unavailable in a Test Run.</summary>
/// <param name="connectionName">Connection name (included in the exception message).</param>
/// <param name="sql">Unused SQL.</param>
/// <param name="parameters">Unused parameters.</param>
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/>.</returns>
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
string connectionName, string sql, object? parameters = null) =>
throw Unavailable($"Database.QueryAsync(\"{connectionName}\")");
/// <summary>Always throws <see cref="ScriptSandboxException"/>; database access is unavailable in a Test Run.</summary>
/// <param name="connectionName">Connection name (included in the exception message).</param>
/// <param name="sql">Unused SQL.</param>
/// <param name="parameters">Unused parameters.</param>
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/>.</returns>
public Task<int> ExecuteAsync(string connectionName, string sql, object? parameters = null) =>
throw Unavailable($"Database.ExecuteAsync(\"{connectionName}\")");
private static ScriptSandboxException Unavailable(string operation) =>
new($"{operation} is not available in Test Run — database access needs the " +
"central configuration / machine-data databases.");
}
/// <summary>Mirror of ZB.MOM.WW.ScadaBridge.InboundAPI.RouteHelper.</summary>
public class RouteAccessor
{
@@ -5,45 +5,90 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// Read-only database access exposed to inbound API scripts. All ADO.NET stays
/// internal here — scripts call QuerySingle/Query by name and never reference
/// System.Data. Named connections only; parameters are bound (anonymous-object
/// properties become @-prefixed SQL parameters), never string-concatenated.
/// Scoped, parameterized database access exposed to inbound API scripts as
/// <c>InboundScriptContext.Database</c>. This is the dedicated, curated data-access
/// helper authorized by the design doc (Component-InboundAPI.md, "Database access") —
/// not a raw connection handed to the script. All ADO.NET stays internal here: scripts
/// call <see cref="QueryAsync"/> / <see cref="QuerySingleAsync{T}"/> / <see cref="ExecuteAsync"/>
/// by name and never reference <c>System.Data</c>.
///
/// <para>
/// <b>SQL-injection protection (InboundAPI-026).</b> Statement text is authored by the
/// (design-time) method script, but every <em>value</em> is bound as a named SQL
/// parameter (anonymous-object properties become <c>@</c>-prefixed parameters via
/// <see cref="AddParameters"/>) and is NEVER string-concatenated into the command text.
/// Request-derived values therefore reach the database only through parameter binding,
/// closing the injection vector. Connection access is restricted to the named
/// connections configured on the central <see cref="IDatabaseGateway"/> — a script
/// cannot supply an arbitrary connection string.
/// </para>
///
/// <para>
/// <b>Reads and writes are both permitted</b> (InboundAPI-026 design decision): the move-in
/// integration needs to record results, not just read them. Use <see cref="QueryAsync"/>
/// / <see cref="QuerySingleAsync{T}"/> for reads and <see cref="ExecuteAsync"/> for writes.
/// </para>
///
/// <para>
/// <b>Async + deadline-bound (InboundAPI-027).</b> Every call uses the async ADO.NET path
/// end-to-end (no <c>.GetAwaiter().GetResult()</c> blocking a pool thread) and honours the
/// executing method's deadline token on the command itself, with a <see cref="DbCommand.CommandTimeout"/>
/// backstop derived from the method timeout — so a slow query is bounded by the method
/// timeout instead of running unbounded.
/// </para>
/// </summary>
public sealed class InboundDatabaseHelper
{
private readonly IDatabaseGateway? _gateway;
private readonly CancellationToken _ct;
private readonly int _commandTimeoutSeconds;
public InboundDatabaseHelper(IDatabaseGateway? gateway, CancellationToken ct)
{ _gateway = gateway; _ct = ct; }
/// <summary>
/// Initializes the helper.
/// </summary>
/// <param name="gateway">The central database gateway, or null when no gateway is registered (the helper then throws on first use).</param>
/// <param name="ct">The executing method's deadline token; forwarded to every async DB call so a slow query is bounded by the method timeout.</param>
/// <param name="commandTimeout">The method timeout; used to derive a <see cref="DbCommand.CommandTimeout"/> backstop. <see cref="TimeSpan.Zero"/> (the default) leaves the provider default.</param>
public InboundDatabaseHelper(IDatabaseGateway? gateway, CancellationToken ct, TimeSpan commandTimeout = default)
{
_gateway = gateway;
_ct = ct;
_commandTimeoutSeconds = commandTimeout > TimeSpan.Zero
? (int)Math.Ceiling(commandTimeout.TotalSeconds)
: 0;
}
/// <summary>First column of the first row converted to T (default if no rows).</summary>
public T? QuerySingle<T>(string connectionName, string sql, object? parameters = null)
/// <summary>First column of the first row converted to <typeparamref name="T"/> (default if no rows).</summary>
/// <typeparam name="T">The type to convert the scalar result to.</typeparam>
/// <param name="connectionName">Name of a connection configured on the central database gateway.</param>
/// <param name="sql">The SQL statement. Values MUST be supplied via <paramref name="parameters"/>, not concatenated.</param>
/// <param name="parameters">Optional anonymous object whose properties become bound <c>@</c>-prefixed parameters.</param>
/// <returns>The converted scalar, or <c>default</c> when there are no rows / a NULL value.</returns>
public async Task<T?> QuerySingleAsync<T>(string connectionName, string sql, object? parameters = null)
{
if (_gateway is null) throw new InvalidOperationException("Database is not available for this inbound method");
using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult();
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
AddParameters(cmd, parameters);
var result = cmd.ExecuteScalar();
await using var conn = await _gateway.GetConnectionAsync(connectionName, _ct);
await using var cmd = CreateCommand(conn, sql, parameters);
var result = await cmd.ExecuteScalarAsync(_ct);
if (result is null or DBNull) return default;
if (result is T t) return t;
return (T)Convert.ChangeType(result, typeof(T), CultureInfo.InvariantCulture);
}
/// <summary>All rows as column→value dictionaries (case-insensitive keys).</summary>
public IReadOnlyList<IReadOnlyDictionary<string, object?>> Query(
/// <param name="connectionName">Name of a connection configured on the central database gateway.</param>
/// <param name="sql">The SQL statement. Values MUST be supplied via <paramref name="parameters"/>, not concatenated.</param>
/// <param name="parameters">Optional anonymous object whose properties become bound <c>@</c>-prefixed parameters.</param>
/// <returns>The result rows; empty when nothing matches.</returns>
public async Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
string connectionName, string sql, object? parameters = null)
{
if (_gateway is null) throw new InvalidOperationException("Database is not available for this inbound method");
using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult();
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
AddParameters(cmd, parameters);
using var reader = cmd.ExecuteReader();
await using var conn = await _gateway.GetConnectionAsync(connectionName, _ct);
await using var cmd = CreateCommand(conn, sql, parameters);
await using var reader = await cmd.ExecuteReaderAsync(_ct);
var rows = new List<IReadOnlyDictionary<string, object?>>();
while (reader.Read())
while (await reader.ReadAsync(_ct))
{
var row = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < reader.FieldCount; i++)
@@ -56,6 +101,35 @@ public sealed class InboundDatabaseHelper
return rows;
}
/// <summary>
/// Executes a write statement (INSERT/UPDATE/DELETE/DDL) and returns the number of
/// rows affected. Writes are authorized for inbound API scripts (InboundAPI-026);
/// values are still bound as parameters, never concatenated.
/// </summary>
/// <param name="connectionName">Name of a connection configured on the central database gateway.</param>
/// <param name="sql">The SQL statement. Values MUST be supplied via <paramref name="parameters"/>, not concatenated.</param>
/// <param name="parameters">Optional anonymous object whose properties become bound <c>@</c>-prefixed parameters.</param>
/// <returns>The number of rows affected.</returns>
public async Task<int> ExecuteAsync(string connectionName, string sql, object? parameters = null)
{
if (_gateway is null) throw new InvalidOperationException("Database is not available for this inbound method");
await using var conn = await _gateway.GetConnectionAsync(connectionName, _ct);
await using var cmd = CreateCommand(conn, sql, parameters);
return await cmd.ExecuteNonQueryAsync(_ct);
}
private DbCommand CreateCommand(DbConnection conn, string sql, object? parameters)
{
var cmd = conn.CreateCommand();
cmd.CommandText = sql;
// InboundAPI-027: a CommandTimeout backstop derived from the method timeout so a
// slow query cannot outrun the method deadline even if the provider does not
// honour token cancellation mid-statement.
if (_commandTimeoutSeconds > 0) cmd.CommandTimeout = _commandTimeoutSeconds;
AddParameters(cmd, parameters);
return cmd;
}
private static void AddParameters(DbCommand cmd, object? parameters)
{
if (parameters is null) return;
@@ -237,12 +237,13 @@ public class InboundScriptExecutor
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, timeoutCts.Token);
// IpsenMES MoveIn: expose a read-only Database helper to the script.
// Resolve the gateway from a fresh DI scope (declared outside the try so it
// lives until after the script handler runs and is disposed here) so a scoped
// IDatabaseGateway is honoured. GetService (not GetRequiredService) so a method
// that never touches Database still runs even when no IDatabaseGateway is
// registered; the helper throws on first use if built without a gateway.
// IpsenMES MoveIn: expose the scoped, parameterized Database helper to the script
// (reads + writes — InboundAPI-026). Resolve the gateway from a fresh DI scope
// (declared outside the try so it lives until after the script handler runs and is
// disposed here) so a scoped IDatabaseGateway is honoured. GetService (not
// GetRequiredService) so a method that never touches Database still runs even when
// no IDatabaseGateway is registered; the helper throws on first use if built
// without a gateway.
//
// A service provider that cannot produce a scope (e.g. a bare test double with
// no IServiceScopeFactory) is tolerated: the gateway is simply unavailable, so
@@ -265,18 +266,28 @@ public class InboundScriptExecutor
try
{
var gateway = scope?.ServiceProvider.GetService<IDatabaseGateway>();
var dbHelper = new InboundDatabaseHelper(gateway, cts.Token);
// InboundAPI-027: pass the method timeout so the helper derives a
// CommandTimeout backstop and forwards cts.Token to every async DB call —
// a slow query is then bounded by the method deadline.
var dbHelper = new InboundDatabaseHelper(gateway, cts.Token, timeout);
// InboundAPI-016: bind the route helper to the method deadline so a
// routed Route.To(...).Call(...) inherits the method-level timeout
// without the script having to thread the context token by hand.
//
// InboundAPI-029: also pass the raw request-abort token separately so
// Route.To(...).WaitForAttribute(...) can be bounded by its WAIT timeout
// (not the generic method deadline) while still being cancelled by a
// client disconnect — see RouteTarget.WaitForAttribute.
//
// Audit Log #23 (ParentExecutionId): also bind the inbound request's
// ExecutionId so a routed call carries it as ParentExecutionId — the
// spawned site script execution points back at this inbound request.
var context = new InboundScriptContext(
parameters,
route.WithDeadline(cts.Token).WithParentExecutionId(parentExecutionId),
route.WithDeadline(cts.Token)
.WithRequestAborted(cancellationToken)
.WithParentExecutionId(parentExecutionId),
dbHelper,
cts.Token);
@@ -382,7 +393,8 @@ public class InboundScriptContext
public RouteHelper Route { get; }
/// <summary>
/// Read-only database access for the script (named connections; bound parameters).
/// Scoped, parameterized database access for the script (named connections; bound
/// parameters; reads and writes — see <see cref="InboundDatabaseHelper"/>).
/// </summary>
public InboundDatabaseHelper Database { get; }
@@ -396,7 +408,7 @@ public class InboundScriptContext
/// </summary>
/// <param name="parameters">The input parameters for the script.</param>
/// <param name="route">The route helper for cross-site routing.</param>
/// <param name="database">The read-only database helper for the script.</param>
/// <param name="database">The scoped, parameterized database helper for the script.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public InboundScriptContext(
IReadOnlyDictionary<string, object?> parameters,
@@ -19,6 +19,7 @@ public class RouteHelper
private readonly IInstanceLocator _instanceLocator;
private readonly IInstanceRouter _instanceRouter;
private readonly CancellationToken _deadlineToken;
private readonly CancellationToken _requestAbortedToken;
private readonly Guid? _parentExecutionId;
/// <summary>
@@ -29,7 +30,7 @@ public class RouteHelper
public RouteHelper(
IInstanceLocator instanceLocator,
IInstanceRouter instanceRouter)
: this(instanceLocator, instanceRouter, CancellationToken.None, parentExecutionId: null)
: this(instanceLocator, instanceRouter, CancellationToken.None, CancellationToken.None, parentExecutionId: null)
{
}
@@ -37,11 +38,13 @@ public class RouteHelper
IInstanceLocator instanceLocator,
IInstanceRouter instanceRouter,
CancellationToken deadlineToken,
CancellationToken requestAbortedToken,
Guid? parentExecutionId)
{
_instanceLocator = instanceLocator;
_instanceRouter = instanceRouter;
_deadlineToken = deadlineToken;
_requestAbortedToken = requestAbortedToken;
_parentExecutionId = parentExecutionId;
}
@@ -55,7 +58,20 @@ public class RouteHelper
/// <param name="deadlineToken">The executing method's timeout cancellation token to inherit for routed calls.</param>
/// <returns>A new <see cref="RouteHelper"/> inheriting the given deadline token.</returns>
public RouteHelper WithDeadline(CancellationToken deadlineToken) =>
new(_instanceLocator, _instanceRouter, deadlineToken, _parentExecutionId);
new(_instanceLocator, _instanceRouter, deadlineToken, _requestAbortedToken, _parentExecutionId);
/// <summary>
/// InboundAPI-029: returns a <see cref="RouteHelper"/> carrying the raw request-abort
/// token (a client disconnect) <em>separately</em> from the method deadline. Most
/// routed calls remain bounded by the method deadline (which already incorporates the
/// abort), but <see cref="RouteTarget.WaitForAttribute"/> 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.
/// </summary>
/// <param name="requestAbortedToken">The raw client-disconnect token, independent of the method timeout.</param>
/// <returns>A new <see cref="RouteHelper"/> carrying the given request-abort token.</returns>
public RouteHelper WithRequestAborted(CancellationToken requestAbortedToken) =>
new(_instanceLocator, _instanceRouter, _deadlineToken, requestAbortedToken, _parentExecutionId);
/// <summary>
/// Audit Log #23 (ParentExecutionId): returns a <see cref="RouteHelper"/> whose
@@ -69,7 +85,7 @@ public class RouteHelper
/// <param name="parentExecutionId">The inbound request's execution id to stamp as the spawning parent on routed calls, or null for non-routed runs.</param>
/// <returns>A new <see cref="RouteHelper"/> carrying the given parent execution id.</returns>
public RouteHelper WithParentExecutionId(Guid? parentExecutionId) =>
new(_instanceLocator, _instanceRouter, _deadlineToken, parentExecutionId);
new(_instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, parentExecutionId);
/// <summary>
/// Creates a route target for the specified instance.
@@ -79,7 +95,7 @@ public class RouteHelper
public RouteTarget To(string instanceCode)
{
return new RouteTarget(
instanceCode, _instanceLocator, _instanceRouter, _deadlineToken, _parentExecutionId);
instanceCode, _instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, _parentExecutionId);
}
}
@@ -88,10 +104,18 @@ public class RouteHelper
/// </summary>
public class RouteTarget
{
// InboundAPI-029: a small grace past the wait timeout. The SITE enforces the wait
// timeout and returns Matched=false when it elapses; the local backstop fires only
// if the site fails to respond, so it must sit slightly LATER than the wait timeout
// (it must not pre-empt the site's own timed-out response and turn a clean `false`
// into a cancellation).
private static readonly TimeSpan WaitResponseGrace = TimeSpan.FromSeconds(5);
private readonly string _instanceCode;
private readonly IInstanceLocator _instanceLocator;
private readonly IInstanceRouter _instanceRouter;
private readonly CancellationToken _deadlineToken;
private readonly CancellationToken _requestAbortedToken;
private readonly Guid? _parentExecutionId;
/// <summary>
@@ -101,18 +125,21 @@ public class RouteTarget
/// <param name="instanceLocator">Service to resolve the site id for the instance.</param>
/// <param name="instanceRouter">Service to route cross-site calls.</param>
/// <param name="deadlineToken">Cancellation token representing the method-level deadline.</param>
/// <param name="requestAbortedToken">Raw client-disconnect token, independent of the method timeout (InboundAPI-029).</param>
/// <param name="parentExecutionId">Optional parent execution id for audit correlation on routed calls.</param>
internal RouteTarget(
string instanceCode,
IInstanceLocator instanceLocator,
IInstanceRouter instanceRouter,
CancellationToken deadlineToken,
CancellationToken requestAbortedToken,
Guid? parentExecutionId)
{
_instanceCode = instanceCode;
_instanceLocator = instanceLocator;
_instanceRouter = instanceRouter;
_deadlineToken = deadlineToken;
_requestAbortedToken = requestAbortedToken;
_parentExecutionId = parentExecutionId;
}
@@ -211,11 +238,22 @@ public class RouteTarget
/// wire: the target is canonically encoded via <see cref="AttributeValueCodec"/> and
/// the site evaluates equality — there is no predicate and no quality flag in the
/// comparison.
///
/// <para>
/// InboundAPI-029: unlike the other routed calls (which inherit the method deadline
/// per InboundAPI-016), the wait is bounded by <paramref name="timeout"/> — the WAIT
/// timeout — NOT the generic method deadline. The SITE enforces <paramref name="timeout"/>
/// and returns <c>Matched=false</c> 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.
/// </para>
/// </summary>
/// <param name="attributeName">Name of the attribute to wait on.</param>
/// <param name="targetValue">Target value the attribute must equal for the wait to match.</param>
/// <param name="timeout">Maximum time to wait for the attribute to reach the target value.</param>
/// <param name="cancellationToken">Optional cancellation token; defaults to the method deadline.</param>
/// <param name="timeout">Maximum time to wait for the attribute to reach the target value; this — not the method deadline — bounds the wait.</param>
/// <param name="cancellationToken">Optional explicit cancellation token (a tighter caller-supplied bound); the wait is otherwise bounded by <paramref name="timeout"/>.</param>
/// <returns>A task that resolves to <c>true</c> if the attribute reached the target value, <c>false</c> if the wait timed out.</returns>
public async Task<bool> WaitForAttribute(
string attributeName,
@@ -223,7 +261,12 @@ public class RouteTarget
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
var token = Effective(cancellationToken);
// InboundAPI-029: bound the wait by the WAIT timeout (+ grace backstop), the
// client-disconnect token, and an explicit caller token — NOT the method deadline.
using var waitCts = new CancellationTokenSource(timeout + WaitResponseGrace);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
waitCts.Token, _requestAbortedToken, cancellationToken);
var token = linked.Token;
var siteId = await ResolveSiteAsync(token);
// Audit Log #23 (ParentExecutionId): mirrors the Call path — stamp the