Files
ScadaBridge/archreview/plans/PLAN-R2-06-edge-integrations.md
T
Joseph Doherty 5bbd7689fa docs(archreview): round-2 re-review (2026-07-12) + 8 fix plans (86 tasks)
Re-ran all 8 domain reviews at HEAD 8c888f13 against the b910f5eb baseline:
every round-1 finding source-verified (168 fixed, 0 regressions, 0 false
claims); 56 new findings (1 Critical / 4 High / 15 Medium / 36 Low),
concentrated in post-baseline code (anti-entropy resync, KPI rollup
backfill, live alarm stream) and seams the fixes exposed.

Headliners: S&F resync predicate inversion can wipe the delivering node's
buffer (02-N1 Critical); resync snapshot exceeds the Akka remoting frame
size (02-N2); failover drill kills the one node keep-oldest can't survive
(01-N1); unbounded rollup backfill per failover (04-R1); live production
API key in untracked test.txt (08-NF1).

Adds PLAN-R2-01..08 + .tasks.json manifests and the Round-2 board,
P0 list, cross-plan mutexes, and wave order in 00-MASTER-TRACKER.
2026-07-12 23:52:10 -04:00

51 KiB
Raw Blame History

PLAN-R2-06 — Edge Integrations Round-2 Fixes

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

Goal: Close every NEW finding in archreview/06-edge-integrations.md (round 2, 2026-07-12): wire the Inbound API compiled-handler cache as the real IScriptArtifactChangeBus consumer the Host comment and master tracker already claim exists (N1, the round-1 U2 residue) and sweep the false claims out of the docs; park (not retry-to-exhaustion) buffered ESG calls that fail deterministically with ArgumentException (N2); replace SITE_UNREACHABLE message-substring sniffing with a typed signal at the Ask boundary (N3); honor the declared response charset in the bounded body read (N4); and close the chunked-body bypass of the 415 guard (N5).

Architecture: The N1 consumer is a small IHostedService (ScriptArtifactChangeSubscriber) owned by the InboundAPI component and registered in AddInboundAPI: on start it calls IScriptArtifactChangeBus.Subscribe and, for every ApiMethod notification, calls the existing InboundScriptExecutor.InvalidateMethod seam (InboundScriptExecutor.cs:136-140) per name — the per-request revision check stays the correctness fallback per the contract's normative semantics (docs/plans/2026-07-08-script-artifact-invalidation-contract.md §4), so the subscriber is a fast-path, never a correctness dependency. The bus dependency is optional (IScriptArtifactChangeBus? = null, mirroring BundleImporter.cs:134) so site roles and plain test hosts no-op. N3 deliberately chooses the typed-exception form over an additive error-code field: unreachability manifests at the communication layer as Akka.Actor.AskTimeoutException (the site never answered — see CentralCommunicationActor.HandleSiteEnvelope's no-ClusterClient path, which deliberately lets the Ask expire), so RouteHelper types it into SiteUnreachableException at the four router awaits and the message sniff (RouteHelper.cs:391-395) is deleted — a Success=false response means the site answered, which is by definition not unreachability. No Commons message contract changes anywhere in this plan — the PLAN-08 contract-lock suite (ClusterClientContractLockTests.cs) is untouched; the RouteTo*Response records are not among the locked seven in any case, and this plan does not modify them. N2/N4/N5 are each one-file classification/decoding/guard fixes with the failing test being exactly the report's scenario.

Tech Stack: C#/.NET 9, xUnit + NSubstitute, ASP.NET Core TestServer (inbound endpoint tests), Roslyn scripting (real compiles in executor tests), Akka.NET (AskTimeoutException, transitively referenced via Communication). No EF migrations, no ManagementActor.cs edits, no Commons message-contract edits.

Build: dotnet build ZB.MOM.WW.ScadaBridge.slnx. Test per-project: dotnet test tests/<project> — targeted filters only, per task.

Parallelization (incomplete tasks) — 2026-07-13

Nothing complete yet. Ready now (blockedBy empty): {1, 3, 5, 6}.

Concurrent lanes (dispatch as separate implementers, fully file-disjoint):

  • Inbound consumer lane: T1 (ScriptArtifactChangeSubscriber.cs new + ServiceCollectionExtensions.cs + the Host/Program.cs:95-98 comment) → T2 (docs/tracker truth sweep — no code).
  • ESG client lane (single-writer on ExternalSystemClient.cs + ExternalSystemClientTests.cs): T3 → T4, serial.
  • Inbound routing lane: T5 (RouteHelper.cs + executor/route tests).
  • Inbound endpoint lane: T6 (EndpointExtensions.cs + EndpointContentTypeTests.cs).

Cross-plan file notes: T1 touches Host/Program.cs (comment only, lines 95-98) — coordinate if another active plan holds the Program.cs mutex. Nothing here touches ManagementActor.cs, BundleImporter.cs, or the EF model snapshot.


Task 1: ScriptArtifactChangeSubscriber — wire the Inbound API as the bus's ApiMethod consumer

Classification: standard (new consumer wiring on the hot handler cache; safe-by-design — the per-request revision check remains the correctness fallback, so a missed/duplicate notification is harmless) Estimated implement time: ~5 min Parallelizable with: 3, 4, 5, 6 Files:

  • Create: src/ZB.MOM.WW.ScadaBridge.InboundAPI/ScriptArtifactChangeSubscriber.cs
  • Modify: src/ZB.MOM.WW.ScadaBridge.InboundAPI/ServiceCollectionExtensions.cs (lines 12-31: register the hosted service)
  • Modify: src/ZB.MOM.WW.ScadaBridge.Host/Program.cs (lines 95-98: rewrite the factually-wrong comment)
  • Test (create): tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ScriptArtifactChangeSubscriberTests.cs

The bus contract shipped in PLAN-05 T14 (IScriptArtifactChangeBus, InProcessScriptArtifactChangeBus, the BundleImporter post-commit publisher at BundleImporter.cs:1811), and PLAN-06 T3 shipped the InvalidateMethod seam — but a repo-wide search confirms zero Subscribe call sites: the bus publishes into the void while Host/Program.cs:96-98 claims "the Inbound API compiled-handler cache subscribes as the consumer (wired in plan 06)". This task ships the one real consumer: an IHostedService in the InboundAPI project (component-owned, unit-testable — unlike a lambda in the Host composition root). SharedScript/Template notifications are ignored by this consumer (the executor caches only ApiMethod handlers); their no-consumer status is documented honestly in Task 2.

Steps:

  1. Write the failing tests:
// tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ScriptArtifactChangeSubscriberTests.cs
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;

namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;

/// <summary>
/// Arch-review R2 N1: the Inbound API must be a REAL subscriber of
/// IScriptArtifactChangeBus — a bundle-import publish must invalidate a cached
/// handler immediately, without waiting for the per-request revision-check
/// self-heal (which remains the correctness fallback per the invalidation
/// contract's normative semantics).
/// </summary>
public class ScriptArtifactChangeSubscriberTests
{
    /// <summary>Minimal in-test bus: records subscriptions, delivers synchronously.</summary>
    private sealed class RecordingBus : IScriptArtifactChangeBus
    {
        private readonly List<Action<ScriptArtifactsChanged>> _handlers = new();
        public int SubscriberCount => _handlers.Count;

        public void Publish(ScriptArtifactsChanged notification)
        {
            foreach (var handler in _handlers.ToArray()) handler(notification);
        }

        public IDisposable Subscribe(Action<ScriptArtifactsChanged> handler)
        {
            _handlers.Add(handler);
            return new Token(() => _handlers.Remove(handler));
        }

        private sealed class Token(Action dispose) : IDisposable
        {
            public void Dispose() => dispose();
        }
    }

    private readonly InboundScriptExecutor _executor = new(
        NullLogger<InboundScriptExecutor>.Instance, Substitute.For<IServiceProvider>());
    private readonly RecordingBus _bus = new();
    private readonly RouteHelper _route = new(
        Substitute.For<IInstanceLocator>(), Substitute.For<IInstanceRouter>());

    private ScriptArtifactChangeSubscriber CreateSubscriber(IScriptArtifactChangeBus? bus) =>
        new(_executor, NullLogger<ScriptArtifactChangeSubscriber>.Instance, bus);

    private Task<InboundScriptResult> Run(ApiMethod m) => _executor.ExecuteAsync(
        m, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));

    private static ScriptArtifactsChanged ApiMethodChanged(params string[] names) =>
        new(ScriptArtifactKinds.ApiMethod, names, "BundleImport", DateTimeOffset.UtcNow);

    [Fact]
    public async Task ApiMethodPublish_InvalidatesCachedHandler_WithoutRevisionCheckSelfHeal()
    {
        var subscriber = CreateSubscriber(_bus);
        await subscriber.StartAsync(CancellationToken.None);

        // Pin a SCRIPT-LESS handler: the RegisterHandler seam is exempt from the
        // per-request revision check, so the ONLY mechanism that can stop it
        // serving is an explicit InvalidateMethod — exactly what the bus must trigger.
        _executor.RegisterHandler("m", async _ => { await Task.CompletedTask; return 99; });
        var row = new ApiMethod("m", "return 1;") { Id = 1, TimeoutSeconds = 10 };

        var before = await Run(row);
        Assert.Contains("99", before.ResultJson); // pinned handler serves; no self-heal fires

        _bus.Publish(ApiMethodChanged("m"));

        var after = await Run(row);
        Assert.True(after.Success);
        Assert.Contains("1", after.ResultJson); // recompiled from the fresh row — invalidation, not self-heal
    }

    [Fact]
    public async Task ApiMethodPublish_PurgesKnownBadRecord()
    {
        var subscriber = CreateSubscriber(_bus);
        await subscriber.StartAsync(CancellationToken.None);

        var broken = new ApiMethod("bad", "%%% not C# %%%") { Id = 2, TimeoutSeconds = 10 };
        Assert.False(_executor.CompileAndRegister(broken));
        Assert.Equal(1, _executor.KnownBadMethodCount);

        _bus.Publish(ApiMethodChanged("bad"));

        Assert.Equal(0, _executor.KnownBadMethodCount);
    }

    [Fact]
    public async Task NonApiMethodKinds_AreIgnored()
    {
        var subscriber = CreateSubscriber(_bus);
        await subscriber.StartAsync(CancellationToken.None);

        _executor.RegisterHandler("m", async _ => { await Task.CompletedTask; return 99; });
        var row = new ApiMethod("m", "return 1;") { Id = 3, TimeoutSeconds = 10 };

        _bus.Publish(new ScriptArtifactsChanged(
            ScriptArtifactKinds.SharedScript, new[] { "m" }, "BundleImport", DateTimeOffset.UtcNow));
        _bus.Publish(new ScriptArtifactsChanged(
            ScriptArtifactKinds.Template, new[] { "m" }, "BundleImport", DateTimeOffset.UtcNow));

        var result = await Run(row);
        Assert.Contains("99", result.ResultJson); // pinned handler untouched
    }

    [Fact]
    public async Task StopAsync_Unsubscribes()
    {
        var subscriber = CreateSubscriber(_bus);
        await subscriber.StartAsync(CancellationToken.None);
        Assert.Equal(1, _bus.SubscriberCount);

        await subscriber.StopAsync(CancellationToken.None);

        Assert.Equal(0, _bus.SubscriberCount);
    }

    [Fact]
    public async Task NullBus_NoOps()
    {
        // Site roles / plain test hosts have no bus registered — the hosted
        // service must start and stop cleanly (the revision check alone applies).
        var subscriber = CreateSubscriber(bus: null);
        await subscriber.StartAsync(CancellationToken.None);
        await subscriber.StopAsync(CancellationToken.None);
    }
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests --filter ScriptArtifactChangeSubscriberTests — expect FAIL (compile error: no ScriptArtifactChangeSubscriber type).

  2. Implement src/ZB.MOM.WW.ScadaBridge.InboundAPI/ScriptArtifactChangeSubscriber.cs:

using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;

namespace ZB.MOM.WW.ScadaBridge.InboundAPI;

/// <summary>
/// N1 (arch-review 06 round 2): the Inbound API consumer of the script-artifact
/// invalidation contract (docs/plans/2026-07-08-script-artifact-invalidation-contract.md,
/// handoff item (a)). Subscribes to <see cref="IScriptArtifactChangeBus"/> on host
/// start and, for every <c>ApiMethod</c> notification (e.g. a Transport bundle
/// import overwriting method scripts, published post-commit), drops the compiled
/// handler AND the known-bad record via
/// <see cref="InboundScriptExecutor.InvalidateMethod"/>, so the next request
/// recompiles from the fresh row without waiting for the per-request
/// revision-check self-heal.
/// <para>
/// Fast-path only: the revision check in <c>ExecuteAsync</c> remains the
/// correctness fallback — this bus is in-process/per-node and at-least-once
/// (contract §4), so a missed or duplicate notification must always be harmless.
/// The bus is optional (site roles and plain test hosts register none) — the
/// subscriber then no-ops. SharedScript/Template notifications are ignored: this
/// executor caches only ApiMethod handlers.
/// </para>
/// </summary>
public sealed class ScriptArtifactChangeSubscriber : IHostedService
{
    private readonly InboundScriptExecutor _executor;
    private readonly ILogger<ScriptArtifactChangeSubscriber> _logger;
    private readonly IScriptArtifactChangeBus? _bus;
    private IDisposable? _subscription;

    /// <summary>Initializes the subscriber.</summary>
    /// <param name="executor">The compiled-handler cache to invalidate.</param>
    /// <param name="logger">Logger instance.</param>
    /// <param name="bus">The change bus, or null when the host registers none (site roles, tests).</param>
    public ScriptArtifactChangeSubscriber(
        InboundScriptExecutor executor,
        ILogger<ScriptArtifactChangeSubscriber> logger,
        IScriptArtifactChangeBus? bus = null)
    {
        _executor = executor;
        _logger = logger;
        _bus = bus;
    }

    /// <inheritdoc />
    public Task StartAsync(CancellationToken cancellationToken)
    {
        if (_bus is null)
        {
            _logger.LogDebug(
                "No IScriptArtifactChangeBus registered; inbound handler-cache freshness relies on the per-request revision check alone.");
            return Task.CompletedTask;
        }

        _subscription = _bus.Subscribe(OnChanged);
        return Task.CompletedTask;
    }

    /// <inheritdoc />
    public Task StopAsync(CancellationToken cancellationToken)
    {
        _subscription?.Dispose();
        _subscription = null;
        return Task.CompletedTask;
    }

    private void OnChanged(ScriptArtifactsChanged notification)
    {
        if (!string.Equals(
                notification.ArtifactKind, ScriptArtifactKinds.ApiMethod, StringComparison.Ordinal))
        {
            return; // only ApiMethod handlers are cached by this executor
        }

        foreach (var name in notification.Names)
        {
            _executor.InvalidateMethod(name);
        }

        _logger.LogInformation(
            "Invalidated {Count} inbound API method handler(s) after a {Source} script-artifact change.",
            notification.Names.Count, notification.Source);
    }
}

In ServiceCollectionExtensions.AddInboundAPI (after the InboundApiEndpointFilter registration):

// N1: the ApiMethod consumer of the script-artifact invalidation bus.
// The bus parameter is optional — hosts without a registered
// IScriptArtifactChangeBus (site roles, plain test hosts) start it as a no-op
// and the executor's per-request revision check alone keeps handlers fresh.
services.AddHostedService<ScriptArtifactChangeSubscriber>();

In Host/Program.cs replace the comment at lines 95-98 with the truth:

// Script-artifact invalidation bus: in-process, per-node pub/sub
// for ScriptArtifactsChanged. The Transport bundle importer publishes
// post-commit; the Inbound API's ScriptArtifactChangeSubscriber (a hosted
// service registered by AddInboundAPI below) consumes ApiMethod notifications
// and invalidates the compiled-handler cache (wired in plan R2-06 T1; the
// per-request revision check remains the correctness fallback).
// SharedScript/Template notifications currently have NO consumer — nothing at
// central caches compiled artifacts of those kinds. Central-only.
  1. Run the filter → PASS, then the whole project: dotnet test tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests → all green (the existing TestServer-based hosts call AddInboundAPI + StartAsync with no bus registered — the optional-parameter default keeps them green; this is the same optional-dependency pattern BundleImporter already uses). Then dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests → green (Program.cs change is comment-only).
  2. Commit: git add src/ZB.MOM.WW.ScadaBridge.InboundAPI/ScriptArtifactChangeSubscriber.cs src/ZB.MOM.WW.ScadaBridge.InboundAPI/ServiceCollectionExtensions.cs src/ZB.MOM.WW.ScadaBridge.Host/Program.cs tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ScriptArtifactChangeSubscriberTests.cs && git commit -m "fix(inbound): wire the compiled-handler cache as the IScriptArtifactChangeBus ApiMethod consumer (plan R2-06 T1)"

Task 2: Truth sweep — tracker / contract-doc / CLAUDE.md claims about the bus consumer

Classification: small (documentation) Estimated implement time: ~4 min Parallelizable with: 3, 4, 5, 6 (after 1) Files:

  • Modify: archreview/plans/00-MASTER-TRACKER.md (line 36 Wave-4 summary: the "InvalidateMethod seam consuming PLAN-05's IScriptArtifactChangeBus contract (T3)" claim; line 98 cross-plan handoff bullet)
  • Modify: docs/plans/2026-07-08-script-artifact-invalidation-contract.md (line 3 status; §"Handoff to plan 06" rows (a)/(b)/(c), line 58 "no subscribers" sentence)
  • Modify: CLAUDE.md (Templates & Deployment section, the "Plan-06 handoff: … (consumers = plan 06)" sentence)

N1's second half: the report calls the Host comment (fixed in Task 1) and the tracker/doc claims factually wrong — "will misdirect the next person debugging cache coherence." After Task 1 the consumer genuinely exists, so this task makes every claim match reality, including the honest residue: (b) Management producers are still unwired by design (the delete path calls InvalidateMethod directly at ManagementActor.cs:2953; update/edit paths self-heal via the per-request revision check — no notification needed for correctness), and (c) is satisfied by the revision check (PLAN-06 T1). SharedScript/Template kinds still publish with no consumer — that is now documented as safe (nothing at central caches compiled artifacts of those kinds) instead of silently misdescribed.

Steps:

  1. 00-MASTER-TRACKER.md line 36: change "InvalidateMethod seam consuming PLAN-05's IScriptArtifactChangeBus contract (T3)" to "InvalidateMethod seam (T3; the bus subscriber was NOT wired here — arch-review 06 round-2 N1 — and shipped later via PLAN-R2-06 T1)". Line 98: append to the PLAN-05 T14 ↔ PLAN-06 T3 bullet: "Closed 2026-07-13 (PLAN-R2-06 T1): ScriptArtifactChangeSubscriber (InboundAPI hosted service) subscribes and invalidates on ApiMethod notifications."
  2. docs/plans/2026-07-08-script-artifact-invalidation-contract.md: line 3 status → "Seam + first producer shipped in PLAN-05 (T14). Consumer (a) shipped in PLAN-R2-06 T1 (ScriptArtifactChangeSubscriber). (b) Management producers remain unwired by design — delete calls InvalidateMethod directly; edits self-heal via the revision check. (c) is satisfied by the per-request revision check (PLAN-06 T1)." Update the handoff table rows to the same dispositions and rewrite line 58 ("Until (a)(c) land, plan 05's publisher is a no-op in practice…") to past tense with the shipped/unwired split.
  3. CLAUDE.md: change "(consumers = plan 06)" in the Transport bullet to "(ApiMethod consumer = InboundAPI ScriptArtifactChangeSubscriber, wired in plan R2-06; SharedScript/Template publish with no consumer — nothing at central caches those kinds)".
  4. Verify no stale claim remains: grep -rn "wired in plan 06\|consumers = plan 06" src docs CLAUDE.md archreview/plans/00-MASTER-TRACKER.md → zero hits (Task 1 already rewrote the Program.cs comment).
  5. Commit: git add archreview/plans/00-MASTER-TRACKER.md docs/plans/2026-07-08-script-artifact-invalidation-contract.md CLAUDE.md && git commit -m "docs(inbound): correct bus-consumer claims in tracker + invalidation-contract doc + CLAUDE.md (plan R2-06 T2)"

Task 3: DeliverBufferedAsync parks deterministic ArgumentException failures (path template / verb)

Classification: small (classification-only change on the buffered path, mirroring the existing JsonException poison fix in the same method) Estimated implement time: ~4 min Parallelizable with: 1, 2, 5, 6 Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs (DeliverBufferedAsync, lines 214-225: add the catch)
  • Test: tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs (append)

N2: BuildUrl's {param} substitution throws ArgumentException for a placeholder with no matching/non-null parameter (ExternalSystemClient.cs:535-537) and ValidateHttpMethod throws the same type (:423-438). Neither is caught in DeliverBufferedAsync (its try at :214-224 catches only PermanentExternalSystemException), and the S&F retry sweep's catch-all treats any thrown exception as transient (StoreAndForwardService.cs:822-830) — so a method path edited to add {id} while calls are buffered burns the full default 50 retries (StoreAndForwardOptions.cs:21) with a misleading "transient" LastError before parking. Re-running the same substitution against the same stored payload throws deterministically — same poison shape as the JsonException fix at :184-195. No StoreAndForwardService change: the classification decision belongs to the delivery handler, exactly like the existing cases.

Steps:

  1. Failing tests (append; reuse the existing StubResolution/BufferedCall/MockHttpMessageHandler helpers — BufferedCall builds a payload with Parameters: null, which is exactly the unresolvable-placeholder case):
[Fact]
public async Task DeliverBuffered_PathTemplateNowUnresolvable_ReturnsFalseSoMessageParks()
{
    // Arch-review R2 N2: a method whose path gained a {param} placeholder AFTER
    // calls were buffered throws ArgumentException deterministically for every
    // already-buffered message — it must park immediately (permanent), not burn
    // MaxRetries "transient" attempts (the S&F sweep's catch-all retries any throw).
    var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
    var method = new ExternalSystemMethod("getRecipe", "GET", "/recipes/{id}") { Id = 1, ExternalSystemDefinitionId = 1 };
    StubResolution(system, method);
    _httpClientFactory.CreateClient(Arg.Any<string>())
        .Returns(new HttpClient(new MockHttpMessageHandler(HttpStatusCode.OK, "{}")));
    var client = new ExternalSystemClient(
        _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

    // BufferedCall carries Parameters:null — the {id} placeholder has no value.
    var delivered = await client.DeliverBufferedAsync(BufferedCall("TestAPI", "getRecipe"));

    Assert.False(delivered);
}

[Fact]
public async Task DeliverBuffered_UnsupportedVerbOnStoredMethod_ReturnsFalseSoMessageParks()
{
    // ValidateHttpMethod throws the same deterministic ArgumentException shape.
    var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
    var method = new ExternalSystemMethod("oddVerb", "FOO", "/p") { Id = 1, ExternalSystemDefinitionId = 1 };
    StubResolution(system, method);
    var client = new ExternalSystemClient(
        _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

    var delivered = await client.DeliverBufferedAsync(BufferedCall("TestAPI", "oddVerb"));

    Assert.False(delivered);
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests --filter "DeliverBuffered_PathTemplateNowUnresolvable_ReturnsFalseSoMessageParks|DeliverBuffered_UnsupportedVerbOnStoredMethod_ReturnsFalseSoMessageParks"FAIL (both tests die on the propagating ArgumentException instead of returning false).
  2. Implement — in DeliverBufferedAsync, after the catch (PermanentExternalSystemException …) branch (line 223), add:
catch (ArgumentException ex)
{
    // N2: same poison-message shape as the JsonException catch above. BuildUrl's
    // {param} path-template substitution (placeholder with no matching/non-null
    // parameter) and ValidateHttpMethod both throw ArgumentException
    // deterministically for the SAME stored payload — the method definition
    // changed underneath the buffered call, and retrying can never succeed.
    // Return false so the S&F engine parks the message instead of counting the
    // throw as transient and burning MaxRetries.
    _logger.LogError(
        ex,
        "Buffered call to '{System}'/'{Method}' fails deterministically against the current method definition; parking.",
        payload.SystemName, payload.MethodName);
    return false;
}

Also extend the method's <summary> doc-comment (lines 165-173) to name ArgumentException alongside malformed JSON as a park-immediately case. 4. Run the filter → PASS; then the full project dotnet test tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests → green; consumer-suite check (no S&F files touched, classification contract only): dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests → green. 5. Commit: git add src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs && git commit -m "fix(esg): park buffered calls that fail deterministically with ArgumentException (path template/verb) instead of retrying to exhaustion (plan R2-06 T3)"

Task 4: Honor the declared response charset in ReadBodyBoundedAsync

Classification: small (decode-only; restores round-1 ReadAsStringAsync parity) Estimated implement time: ~4 min Parallelizable with: 1, 2, 5, 6 (NOT 3 — same file; run after 3) Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs (ReadBodyBoundedAsync, line 482 + new private helper)
  • Test: tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs (append)

N4: the bounded read ends with Encoding.UTF8.GetString(...) (:482) regardless of Content-Type: …; charset=iso-8859-1 — a legacy external system replying in a non-UTF-8 single-byte encoding becomes mojibake in script-visible response text and error bodies. The pre-P1 ReadAsStringAsync honored the charset; restore parity: read content.Headers.ContentType?.CharSet, Encoding.GetEncoding it (strip optional quotes), fall back to UTF-8 on unknown/invalid values (mojibake beats a hard failure for a cosmetic header).

Steps:

  1. Failing tests (append; add the small byte-body handler beside MockHttpMessageHandler at line ~1100):
[Fact]
public async Task Call_Latin1Charset_DecodesUsingDeclaredCharset()
{
    // Arch-review R2 N4: the bounded read must honor the declared charset —
    // UTF-8-decoding an iso-8859-1 body turns every non-ASCII byte into U+FFFD.
    var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
    var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
    StubResolution(system, method);
    var handler = new ByteBodyHandler(
        HttpStatusCode.OK,
        Encoding.Latin1.GetBytes("température 25°C"),
        "text/plain; charset=iso-8859-1");
    _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
    var client = new ExternalSystemClient(
        _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

    var result = await client.CallAsync("TestAPI", "getData");

    Assert.True(result.Success);
    Assert.Contains("température 25°C", result.ResponseJson);
}

[Fact]
public async Task Call_UnknownCharset_FallsBackToUtf8()
{
    var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
    var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
    StubResolution(system, method);
    var handler = new ByteBodyHandler(
        HttpStatusCode.OK, Encoding.UTF8.GetBytes("ok"), "text/plain; charset=not-a-charset");
    _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
    var client = new ExternalSystemClient(
        _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);

    var result = await client.CallAsync("TestAPI", "getData");

    Assert.True(result.Success);
    Assert.Contains("ok", result.ResponseJson);
}

/// <summary>Test helper: returns a fixed byte body with an explicit Content-Type.</summary>
private sealed class ByteBodyHandler(
    HttpStatusCode statusCode, byte[] body, string contentType) : HttpMessageHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var response = new HttpResponseMessage(statusCode) { Content = new ByteArrayContent(body) };
        response.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType);
        return Task.FromResult(response);
    }
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests --filter "Call_Latin1Charset_DecodesUsingDeclaredCharset|Call_UnknownCharset_FallsBackToUtf8"FAIL (Call_Latin1Charset…: UTF-8 decode yields U+FFFD for é/°; the fallback test passes already — it pins the tolerance so the fix cannot regress it into a throw).
  2. Implement — replace line 482 with:
// N4: honor the response's declared charset (parity with the pre-bounded-read
// ReadAsStringAsync). Unknown/invalid charset → UTF-8 fallback: mojibake beats
// failing the call over a cosmetic header.
return ResolveEncoding(content.Headers.ContentType?.CharSet)
    .GetString(buffer.GetBuffer(), 0, (int)buffer.Length);

and add below ReadBodyBoundedAsync:

/// <summary>
/// Resolves the declared response charset to an <see cref="Encoding"/>, tolerating
/// the quoted form some servers emit (<c>charset="iso-8859-1"</c>). Null, empty,
/// or unrecognized values fall back to UTF-8.
/// </summary>
private static Encoding ResolveEncoding(string? charset)
{
    if (string.IsNullOrWhiteSpace(charset))
    {
        return Encoding.UTF8;
    }

    try
    {
        return Encoding.GetEncoding(charset.Trim().Trim('"'));
    }
    catch (ArgumentException)
    {
        return Encoding.UTF8;
    }
}

Also update the ReadBodyBoundedAsync doc-comment (lines 440-450) to state the charset behavior. 4. Run the filter → PASS; full project run dotnet test tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests → green (the oversized-body and timeout tests at :1047 are decode-independent). 5. Commit: git add src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs && git commit -m "fix(esg): honor the declared response charset in ReadBodyBoundedAsync with UTF-8 fallback (plan R2-06 T4)"

Task 5: Typed SITE_UNREACHABLEAskTimeoutException classification replaces message sniffing

Classification: standard (reclassifies the routed-failure taxonomy for every inbound routed call; the change also makes SITE_UNREACHABLE fire for the real unreachable path for the first time) Estimated implement time: ~5 min Parallelizable with: 1, 2, 3, 4, 6 Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.InboundAPI/RouteHelper.cs (lines 16-21: inner-exception ctor; 185, 233, 297, 348: the four router awaits; 364-377: ResolveSiteAsync comment; 379-395: RoutingFailure + delete IsUnreachable)
  • Modify: docs/requirements/Component-InboundAPI.md (error-code table: SITE_UNREACHABLE = "the site did not respond to the routed request (no contact / down / partitioned)")
  • Test: tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs (rewrite the sniff-pinning test at lines 100-124, add the anti-sniff test), tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RouteHelperTests.cs (append)

N3: RouteHelper.IsUnreachable (:391-395) matches "unreachable"/"no contact" substrings in ErrorMessage text produced by another component — a harmless rewording silently downgrades SITE_UNREACHABLE to SCRIPT_ERROR with no test failing at the boundary. The typed signal already exists in the communication layer: an unreachable site means the ClusterClient Ask expires (CentralCommunicationActor.HandleSiteEnvelope deliberately drops enveloped messages for sites with no client so "the Ask will timeout on the caller side"; CommunicationService.RouteTo*Async are plain bounded Asks), surfacing as Akka.Actor.AskTimeoutException — which today falls into the executor's generic catch as SCRIPT_ERROR. Conversely, a Success=false response means the site answered — by definition not unreachability. So: type AskTimeoutException → SiteUnreachableException at the four router awaits, and make RoutingFailure always a plain InvalidOperationException. No Commons message-contract change (the typed-exception form was chosen over an additive error-code field on the RouteTo*Response records, so the PLAN-08 contract-lock suite needs no update; those records are not among the locked seven anyway, and additive-only + default(T)-safe rules would apply if a future change adds such a field).

Steps:

  1. Failing tests. In InboundScriptExecutorTests.cs, rewrite RoutedSiteUnreachable_Returns500WithSiteUnreachableCode (lines 100-124 — it currently pins the sniff by stubbing ErrorMessage: "Site unreachable") and add the anti-sniff case:
[Fact]
public async Task RoutedAskTimeout_Returns500WithSiteUnreachableCode()
{
    // N3: unreachability is the Ask EXPIRING (site never answered) — typed as
    // AskTimeoutException by the communication layer — not a substring in a
    // failure message. Pre-fix this fell into the generic catch as SCRIPT_ERROR.
    var locator = Substitute.For<IInstanceLocator>();
    locator.GetSiteIdForInstanceAsync("X", Arg.Any<CancellationToken>()).Returns("SiteA");
    var router = Substitute.For<IInstanceRouter>();
    router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
        .Returns(Task.FromException<RouteToCallResponse>(
            new Akka.Actor.AskTimeoutException("Timeout after 00:00:30")));
    var route = new RouteHelper(locator, router);

    var method = new ApiMethod("routed", "return await Route.To(\"X\").Call(\"s\");")
    { Id = 7, TimeoutSeconds = 10 };

    var result = await _executor.ExecuteAsync(
        method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10));

    Assert.False(result.Success);
    Assert.Equal("SITE_UNREACHABLE", result.ErrorCode);
}

[Fact]
public async Task SiteRespondedFailure_MessageMentioningUnreachable_IsScriptErrorNotSiteUnreachable()
{
    // N3 anti-sniff: a site that RESPONDED with Success=false is reachable by
    // definition — even when its error text happens to contain "unreachable".
    // Pre-fix the substring sniff misclassified this as SITE_UNREACHABLE.
    var locator = Substitute.For<IInstanceLocator>();
    locator.GetSiteIdForInstanceAsync("X", Arg.Any<CancellationToken>()).Returns("SiteA");
    var router = Substitute.For<IInstanceRouter>();
    router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
        .Returns(ci => new RouteToCallResponse(
            ((RouteToCallRequest)ci[1]).CorrelationId,
            Success: false, ReturnValue: null,
            ErrorMessage: "PLC device unreachable behind the site gateway",
            DateTimeOffset.UtcNow));
    var route = new RouteHelper(locator, router);

    var method = new ApiMethod("routed2", "return await Route.To(\"X\").Call(\"s\");")
    { Id = 8, TimeoutSeconds = 10 };

    var result = await _executor.ExecuteAsync(
        method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10));

    Assert.False(result.Success);
    Assert.Equal("SCRIPT_ERROR", result.ErrorCode);
}

In RouteHelperTests.cs (uses the existing _locator/_router/CreateHelper/SiteResolves scaffolding), append:

[Fact]
public async Task Call_AskTimeout_ThrowsSiteUnreachableException()
{
    SiteResolves("inst", "SiteA");
    _router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
        .Returns(Task.FromException<RouteToCallResponse>(
            new Akka.Actor.AskTimeoutException("Timeout after 00:00:30")));

    await Assert.ThrowsAsync<SiteUnreachableException>(
        () => CreateHelper().To("inst").Call("s"));
}

[Fact]
public async Task GetAttributes_AskTimeout_ThrowsSiteUnreachableException()
{
    SiteResolves("inst", "SiteA");
    _router.RouteToGetAttributesAsync("SiteA", Arg.Any<RouteToGetAttributesRequest>(), Arg.Any<CancellationToken>())
        .Returns(Task.FromException<RouteToGetAttributesResponse>(
            new Akka.Actor.AskTimeoutException("Timeout after 00:00:30")));

    await Assert.ThrowsAsync<SiteUnreachableException>(
        () => CreateHelper().To("inst").GetAttributes(new[] { "a" }));
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests --filter "RoutedAskTimeout_Returns500WithSiteUnreachableCode|SiteRespondedFailure_MessageMentioningUnreachable|AskTimeout_ThrowsSiteUnreachableException"FAIL (Ask-timeout cases surface SCRIPT_ERROR/AskTimeoutException; the anti-sniff case returns SITE_UNREACHABLE).
  2. Implement in RouteHelper.cs (add using Akka.Actor;):
    • SiteUnreachableException gains public SiteUnreachableException(string message, Exception innerException) : base(message, innerException) { } and its class doc-comment is rewritten: thrown when the routed Ask expires (typed AskTimeoutException from the communication layer) — never inferred from message text.
    • Add to RouteTarget:
/// <summary>
/// N3: awaits a routed Ask and TYPES unreachability. AskTimeoutException means
/// the site never answered (no ClusterClient contact, 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).
/// </summary>
private static async Task<TResponse> RouteOrUnreachable<TResponse>(Task<TResponse> 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);
    }
}
  • Wrap the four router awaits, e.g. line 185: var response = await RouteOrUnreachable(_instanceRouter.RouteToCallAsync(siteId, request, token)); (same at 233, 297, 348).
  • Replace RoutingFailure + delete IsUnreachable (lines 379-395):
/// <summary>
/// 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 <see cref="InvalidOperationException"/>
/// (SCRIPT_ERROR at the endpoint). Unreachability is typed at the Ask boundary
/// by <c>RouteOrUnreachable</c> (AskTimeoutException → SiteUnreachableException),
/// replacing the old message-substring sniff that a harmless rewording in the
/// communication layer could silently defeat.
/// </summary>
private static InvalidOperationException RoutingFailure(string message) => new(message);
  • Fix the now-stale comment inside ResolveSiteAsync (lines 368-371) — a null site id is a plain not-found, never sniffed.
  • Update the Component-InboundAPI.md error-code table row for SITE_UNREACHABLE to the typed semantics ("the target site did not respond to the routed request within the routing timeout").
  1. Run the filter → PASS; full project dotnet test tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests → green (no other test stubs unreachable-flavored ErrorMessage text).
  2. Commit: git add src/ZB.MOM.WW.ScadaBridge.InboundAPI/RouteHelper.cs docs/requirements/Component-InboundAPI.md tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RouteHelperTests.cs && git commit -m "fix(inbound): type SITE_UNREACHABLE on AskTimeoutException at the router await instead of message-substring sniffing (plan R2-06 T5)"

Task 6: 415 guard covers chunked (no Content-Length) bodies

Classification: small (guard-condition fix on the endpoint; also fixes lenient parsing of chunked bodies) Estimated implement time: ~4 min Parallelizable with: 1, 2, 3, 4, 5 Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs (lines 182-212: the 415 guard at 187-194 and the parse condition at 206-207)
  • Modify: docs/requirements/Component-InboundAPI.md (the 415 row: "a body — declared by Content-Length or Transfer-Encoding (chunked)")
  • Test: tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointContentTypeTests.cs (append)

N5: the 415 guard keys on ContentLength > 0 (:187); a chunked non-JSON body has null ContentLength, skips the 415, then also skips JSON parsing (the parse condition :206-207 is ContentLength > 0 || ContentType contains json), so body = null and the caller gets a misleading VALIDATION_FAILED — the same error class S9 set out to fix, now confined to the chunked edge. Fix: compute hasBody once (Content-Length positive, OR Content-Length absent with a Transfer-Encoding header present) and use it in both the 415 guard and the parse condition — so a chunked JSON/no-Content-Type body also parses leniently, matching the fixed-length behavior. The round-1 leniencies are preserved: no body → no 415; body with no Content-Type → lenient JSON parse.

Steps:

  1. Failing tests (append to EndpointContentTypeTests, reusing BuildHostAsync/SeedKeyAsync; add the non-seekable content helper so the client can never auto-compute a Content-Length):
[Fact]
public async Task ChunkedNonJsonBody_Returns415()
{
    // Arch-review R2 N5: a chunked body has NULL ContentLength — it must not
    // slip past the 415 guard into a misleading VALIDATION_FAILED.
    const string methodName = "echoChunked";
    var method = new ApiMethod(methodName, "return Parameters[\"value\"];")
    {
        Id = 1,
        TimeoutSeconds = 10,
        ParameterDefinitions = """[{"name":"value","type":"Integer","required":true}]""",
    };
    var repo = Substitute.For<IInboundApiRepository>();
    repo.GetMethodByNameAsync(methodName, Arg.Any<CancellationToken>()).Returns(method);

    using var host = await BuildHostAsync(repo);
    var token = await SeedKeyAsync(host, methodName);
    var client = host.GetTestClient();

    var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
    {
        Content = new ChunkedByteContent(Encoding.UTF8.GetBytes("hello")),
    };
    request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("text/plain");
    request.Headers.TransferEncodingChunked = true;
    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);

    var response = await client.SendAsync(request);
    var body = await response.Content.ReadAsStringAsync();

    Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode);
    Assert.Contains("UNSUPPORTED_MEDIA_TYPE", body);
}

[Fact]
public async Task ChunkedJsonBody_NoContentType_StillParsesLeniently()
{
    // The parse condition must also learn about chunked bodies: a chunked JSON
    // body with no Content-Type previously skipped parsing entirely (body=null →
    // misleading "missing required parameter").
    const string methodName = "echoChunkedJson";
    var method = new ApiMethod(methodName, "return Parameters[\"value\"];")
    {
        Id = 1,
        TimeoutSeconds = 10,
        ParameterDefinitions = """[{"name":"value","type":"Integer","required":true}]""",
    };
    var repo = Substitute.For<IInboundApiRepository>();
    repo.GetMethodByNameAsync(methodName, Arg.Any<CancellationToken>()).Returns(method);

    using var host = await BuildHostAsync(repo);
    var token = await SeedKeyAsync(host, methodName);
    var client = host.GetTestClient();

    var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
    {
        Content = new ChunkedByteContent(Encoding.UTF8.GetBytes("{\"value\":42}")),
    };
    request.Content.Headers.ContentType = null;
    request.Headers.TransferEncodingChunked = true;
    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);

    var response = await client.SendAsync(request);
    var body = await response.Content.ReadAsStringAsync();

    Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    Assert.Contains("42", body);
}

/// <summary>
/// Content whose length is never computable, forcing HttpClient to transmit it
/// chunked (no Content-Length header ever reaches the server).
/// </summary>
private sealed class ChunkedByteContent(byte[] bytes) : HttpContent
{
    protected override Task SerializeToStreamAsync(
        Stream stream, System.Net.TransportContext? context) =>
        stream.WriteAsync(bytes, 0, bytes.Length);

    protected override bool TryComputeLength(out long length)
    {
        length = -1;
        return false;
    }
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests --filter "ChunkedNonJsonBody_Returns415|ChunkedJsonBody_NoContentType_StillParsesLeniently"FAIL (first: 400 VALIDATION_FAILED instead of 415; second: 400 "missing required parameter" instead of 200).
  2. Implement in EndpointExtensions.cs — above the S9 block (line ~182), compute the body signal once, then use it in both places:
// N5: a body is present when Content-Length says so, OR when the request is
// chunked — Transfer-Encoding present and Content-Length (necessarily) absent.
// Computed once and used by BOTH the 415 guard and the JSON parse condition so
// chunked and fixed-length bodies behave identically.
var requestHeaders = httpContext.Request.Headers;
var hasBody = httpContext.Request.ContentLength > 0
    || (httpContext.Request.ContentLength is null && requestHeaders.TransferEncoding.Count > 0);

The 415 guard (lines 187-189) becomes if (hasBody && !string.IsNullOrEmpty(httpContext.Request.ContentType) && httpContext.Request.ContentType.Contains("json", StringComparison.OrdinalIgnoreCase) == false); the parse condition (lines 206-207) becomes if (hasBody || httpContext.Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true). Update the S9 comment block (lines 182-186) to name the chunked case. Update the doc's 415 row. 4. Run the filter → PASS; full project dotnet test tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests → green (NonJsonContentType_WithBody_Returns415 and the lenient no-Content-Type case at EndpointContentTypeTests.cs:96-146 still pass — fixed-length behavior is unchanged, and a zero-length body still never 415s). 5. Commit: git add src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs docs/requirements/Component-InboundAPI.md tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointContentTypeTests.cs && git commit -m "fix(inbound): 415 guard and lenient JSON parse cover chunked (no Content-Length) bodies (plan R2-06 T6)"


Dependencies on other plans

  • PLAN-R2-05 owns the publisher-side Add-skip fix. BundleImporter.PublishScriptArtifactChanges (BundleImporter.cs:1777-1817, publish at :1811) deliberately excludes Add resolutions ("nothing is cached under a brand-new name yet"); revisiting that rationale is PLAN-R2-05's finding. Task 1's subscriber has no blockedBy on it — the bus contract shipped in PLAN-05 T14, the subscriber consumes whatever is published, and InvalidateMethod is safe for unknown names, so when PLAN-R2-05 broadens the publisher the consumer needs zero changes. Files are disjoint (BundleImporter.cs vs InboundAPI/*) — safe to run both plans concurrently.
  • Contract-lock (PLAN-08 T9). This plan touches no Commons message contracts, so ClusterClientContractLockTests.cs (locked records: CreateDataConnectionCommand, DeployInstanceCommand, NotificationSubmit, AttributeValueChanged, GetAttributeRequest, SetStaticAttributeCommand, ManagementEnvelope) needs no update. Task 5 deliberately chose the typed-exception form over an additive error-code field on the RouteTo*Response records; those records are not among the locked seven, but any future additive field there must still be safe at default(T) (the locked reality: Akka's Newtonsoft serializer ignores C# optional-parameter defaults on missing fields).
  • Host/Program.cs mutex. Task 1's edit is comment-only (lines 95-98) — coordinate only if another active plan is editing Program.cs in the same window.

Execution order

P0 (start immediately, independent, file-disjoint): Tasks 1, 3, 5, 6 in parallel lanes.

  • Inbound consumer lane: 1 → 2 (docs truth sweep last, so it records what actually shipped).
  • ESG client lane: 3 → 4 (both edit ExternalSystemClient.cs + the same test file — serial).
  • Inbound routing lane: 5 (free-floating).
  • Inbound endpoint lane: 6 (free-floating).

Intra-plan critical path: 3 → 4 and 1 → 2 (both two deep); everything else is single-task. No EF migrations, no ManagementActor.cs, no initiative-wide mutexes beyond the Program.cs comment note above.

Findings Coverage

# Finding (report section) Severity Task(s)
N1 Script-artifact invalidation bus publishes into the void; Host comment + master tracker claim a consumer that does not exist (the open PLAN-05→PLAN-06 handoff) Medium 1 (subscriber + Program.cs comment), 2 (tracker/contract-doc/CLAUDE.md truth sweep)
N2 Now-unresolvable path template (or bad verb) on a buffered message classified transient and retried to exhaustion (50 retries) before parking with a misleading LastError Low 3
N3 SITE_UNREACHABLE classification is substring-sniffing on another component's error-message text; a rewording silently downgrades it to SCRIPT_ERROR Low 5 (typed AskTimeoutException → SiteUnreachableException at the router await; sniff deleted; no Commons contract change → contract-lock suite untouched)
N4 Bounded response read decodes UTF-8 unconditionally, dropping the round-1 charset honoring (mojibake for non-UTF-8 legacy systems) Low 4
N5 415 guard keyed on ContentLength > 0 is bypassed by chunked non-JSON bodies, resurfacing the misleading-error class S9 fixed Low 6
U2 (round-1 partial) No cache-coherence mechanism for compiled handlers — the revision check shipped but the claimed bus consumer was never wired Underdeveloped Same as N1 → 1, 2 (the report folds the U2 residue into N1)
U4 (round-1 partial) Twilio accept-only: status-callback webhook / per-recipient delivery state Underdeveloped Deferred (unchanged) — stays out of scope per the round-1 ruling (documented in Component-NotificationService.md:95): an inbound webhook endpoint is new attack surface warranting its own design pass. The residual minor (SID rows saved before validation are not retro-checked) is accepted. No task.