# Inbound API: Full Request/Response Audit Capture — Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to execute this plan task-by-task (fresh implementer per task + spec review + code-quality review).

**Goal:** Inbound API audit rows (`AuditChannel.ApiInbound`) capture the FULL request and response body verbatim up to a configurable 1 MB per-body ceiling, instead of the global 8 KB / 64 KB caps. Other channels are untouched.

**Architecture:** Two changes. (1) `AuditLogOptions` gains an `InboundMaxBytes` knob; `DefaultAuditPayloadFilter` branches on `Channel == ApiInbound` to use it. (2) `AuditWriteMiddleware` finally implements the M5-deferred response-body capture — wraps `HttpContext.Response.Body` with a buffering `MemoryStream` swap, reads it after the pipeline runs, restores and flushes the original body. The redaction stages (headers, body regexes, SQL params) keep their existing semantics; only the truncation cap changes for ApiInbound rows. Validated design: `docs/plans/2026-05-23-inbound-api-full-response-audit-design.md`.

**Tech Stack:** .NET 10, xUnit, ASP.NET Core Minimal API, `Microsoft.Extensions.Options.IOptionsMonitor`.

**Ground rules (every task):** create + work on branch `feature/inbound-api-full-response-audit` — never commit to `main`. TDD: failing test first, then minimal implementation, then verify. Edit in place; never edit `infra/*`, `alog.md`, or `docker/*` unless a task names them (none here). Stage with explicit `git add <path>` — never `git add .` / `commit -am`. Solution stays green: `dotnet build ScadaLink.slnx` 0 warnings (`TreatWarningsAsErrors` on). Do not push.

---

## Task 0: Prep — branch, baseline build

**Files:** none.

**Steps:**
1. `git status --short` — confirm you are starting from the `main` revision that already contains commit `0670864` (`docs(audit): design — full request/response capture for inbound API rows`).
2. `git checkout -b feature/inbound-api-full-response-audit`.
3. `git branch --show-current` — expect `feature/inbound-api-full-response-audit`.
4. `dotnet build ScadaLink.slnx` from repo root — expect 0 warnings, 0 errors.

**Acceptance:** on the feature branch; solution builds clean.

**Commit:** none (no changes yet).

---

## Task 1: Add `InboundMaxBytes` to `AuditLogOptions` (TDD)

**What:** New `int InboundMaxBytes` property on `AuditLogOptions` with default 1 048 576 bytes, validated to `[8192, 16777216]`.

**Files:**
- Modify: `src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs` — add property + XML doc.
- Modify: `src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs` — add min/max constants + validation branch.
- Modify: `tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs` — extend the binding test to assert the new field round-trips from JSON.
- Test (new): `tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsValidatorTests.cs` — if this file does not exist, create it with the four cases below; if a validator-tests file already exists (search for it under `tests/ScadaLink.AuditLog.Tests/Configuration/`), extend it instead.

**Step 1: Write the failing tests**

Add to a validator-tests file (create if missing — namespace `ScadaLink.AuditLog.Tests.Configuration`):

```csharp
public class AuditLogOptionsValidatorTests
{
    [Fact]
    public void Validate_InboundMaxBytes_DefaultOptions_IsOneMebibyte()
    {
        // The doc'd default per docs/plans/2026-05-23-inbound-api-full-response-audit-design.md
        // is 1 048 576 bytes (1 MiB). Pin it so a config drift is a test failure,
        // not a silent operational surprise.
        var opts = new AuditLogOptions();
        Assert.Equal(1_048_576, opts.InboundMaxBytes);
    }

    [Theory]
    [InlineData(8_192)]              // documented min
    [InlineData(1_048_576)]          // default
    [InlineData(16_777_216)]         // documented max
    public void Validate_InboundMaxBytes_InRange_Passes(int value)
    {
        var validator = new AuditLogOptionsValidator();
        var opts = new AuditLogOptions { InboundMaxBytes = value };
        Assert.True(validator.Validate(null, opts).Succeeded);
    }

    [Theory]
    [InlineData(0)]
    [InlineData(8_191)]
    [InlineData(16_777_217)]
    [InlineData(int.MaxValue)]
    public void Validate_InboundMaxBytes_OutOfRange_Fails(int value)
    {
        var validator = new AuditLogOptionsValidator();
        var opts = new AuditLogOptions { InboundMaxBytes = value };
        var result = validator.Validate(null, opts);
        Assert.False(result.Succeeded);
        Assert.Contains(
            result.Failures!,
            f => f.Contains(nameof(AuditLogOptions.InboundMaxBytes), StringComparison.Ordinal));
    }
}
```

Extend `AuditLogOptionsBindingTests.AuditLog_Section_Binds_AllFields` — add `"InboundMaxBytes": 524288` to the JSON literal and a matching `Assert.Equal(524_288, opts.InboundMaxBytes)`.

**Step 2: Run tests — confirm they fail**

```
dotnet test tests/ScadaLink.AuditLog.Tests \
  --filter "FullyQualifiedName~AuditLogOptionsValidatorTests|FullyQualifiedName~AuditLogOptionsBindingTests"
```

Expected: the new validator tests fail (no `InboundMaxBytes` property), the binding test fails (property does not bind).

**Step 3: Add the property**

In `AuditLogOptions.cs`, insert after `RetentionDays`:

```csharp
/// <summary>
/// Per-body byte ceiling applied to <see cref="AuditEvent.RequestSummary"/> and
/// <see cref="AuditEvent.ResponseSummary"/> for <see cref="AuditChannel.ApiInbound"/> rows
/// (default 1 MiB). The 8 KiB / 64 KiB default/error caps that apply to other channels
/// do not apply here — inbound traffic captures verbatim up to this ceiling and only
/// then sets <see cref="AuditEvent.PayloadTruncated"/>. See
/// <c>docs/plans/2026-05-23-inbound-api-full-response-audit-design.md</c>.
/// </summary>
public int InboundMaxBytes { get; set; } = 1_048_576;
```

**Step 4: Add the validator branch**

In `AuditLogOptionsValidator.cs`, add the constants beside the existing retention bounds:

```csharp
public const int MinInboundMaxBytes = 8_192;
public const int MaxInboundMaxBytes = 16_777_216;
```

Add the validation block inside `Validate` (after the retention check, before the `return`):

```csharp
if (options.InboundMaxBytes < MinInboundMaxBytes || options.InboundMaxBytes > MaxInboundMaxBytes)
{
    failures.Add(
        $"AuditLog:{nameof(AuditLogOptions.InboundMaxBytes)} ({options.InboundMaxBytes}) " +
        $"must be in [{MinInboundMaxBytes}, {MaxInboundMaxBytes}] bytes.");
}
```

**Step 5: Run tests — confirm they pass**

```
dotnet test tests/ScadaLink.AuditLog.Tests
```

Expected: all green, including the extended binding test and the new validator tests.

**Step 6: Build the whole solution**

```
dotnet build ScadaLink.slnx
```

Expected: 0 warnings, 0 errors.

**Step 7: Commit**

```
git add src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs \
        src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs \
        tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs \
        tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsValidatorTests.cs
git commit -m "feat(auditlog): add AuditLog:InboundMaxBytes option (default 1 MiB, [8 KiB, 16 MiB])"
```

---

## Task 2: Wire `InboundMaxBytes` into `DefaultAuditPayloadFilter` (TDD)

**What:** When `AuditEvent.Channel == AuditChannel.ApiInbound`, the filter selects `InboundMaxBytes` as the truncation cap instead of `DefaultCapBytes` / `ErrorCapBytes`. Redaction stages run unchanged.

**Files:**
- Modify: `src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs` — change the one cap-selection line.
- Test (new): `tests/ScadaLink.AuditLog.Tests/Payload/InboundChannelCapTests.cs` — pin the new behaviour.

**Step 1: Write the failing tests**

Create `tests/ScadaLink.AuditLog.Tests/Payload/InboundChannelCapTests.cs`:

```csharp
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.AuditLog.Configuration;
using ScadaLink.AuditLog.Payload;
using ScadaLink.AuditLog.Tests.Configuration; // for TestOptionsMonitor — confirm namespace via existing file
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Enums;

namespace ScadaLink.AuditLog.Tests.Payload;

/// <summary>
/// Pins the docs/plans/2026-05-23-inbound-api-full-response-audit-design.md
/// inbound carve-out: ApiInbound rows use InboundMaxBytes (default 1 MiB) for
/// RequestSummary / ResponseSummary truncation, NOT DefaultCapBytes /
/// ErrorCapBytes. Other channels keep the existing caps.
/// </summary>
public class InboundChannelCapTests
{
    private static AuditEvent MakeInbound(
        AuditStatus status,
        string? request = null,
        string? response = null) =>
        new()
        {
            EventId = Guid.NewGuid(),
            OccurredAtUtc = DateTime.UtcNow,
            Channel = AuditChannel.ApiInbound,
            Kind = status == AuditStatus.Delivered
                ? AuditKind.InboundRequest
                : AuditKind.InboundRequest,
            Status = status,
            RequestSummary = request,
            ResponseSummary = response,
        };

    [Fact]
    public void ApiInbound_Delivered_RequestBody_BelowInboundMaxBytes_NotTruncated()
    {
        // Body well above the legacy 8 KiB default cap but under the 1 MiB
        // inbound ceiling — must NOT truncate.
        var body = new string('a', 100_000);
        var opts = new AuditLogOptions();          // defaults
        var filter = new DefaultAuditPayloadFilter(
            new TestOptionsMonitor<AuditLogOptions>(opts),
            NullLogger<DefaultAuditPayloadFilter>.Instance);

        var result = filter.Apply(MakeInbound(AuditStatus.Delivered, request: body));

        Assert.False(result.PayloadTruncated);
        Assert.Equal(body.Length, result.RequestSummary!.Length);
    }

    [Fact]
    public void ApiInbound_Delivered_ResponseBody_BelowInboundMaxBytes_NotTruncated()
    {
        var body = new string('a', 100_000);
        var opts = new AuditLogOptions();
        var filter = new DefaultAuditPayloadFilter(
            new TestOptionsMonitor<AuditLogOptions>(opts),
            NullLogger<DefaultAuditPayloadFilter>.Instance);

        var result = filter.Apply(MakeInbound(AuditStatus.Delivered, response: body));

        Assert.False(result.PayloadTruncated);
        Assert.Equal(body.Length, result.ResponseSummary!.Length);
    }

    [Fact]
    public void ApiInbound_Failed_BodyAboveInboundMaxBytes_TruncatedToInboundMaxBytes()
    {
        // Even on error rows, the inbound cap is InboundMaxBytes (NOT ErrorCapBytes).
        var opts = new AuditLogOptions { InboundMaxBytes = 16_384 };
        var oversized = new string('z', 50_000);
        var filter = new DefaultAuditPayloadFilter(
            new TestOptionsMonitor<AuditLogOptions>(opts),
            NullLogger<DefaultAuditPayloadFilter>.Instance);

        var result = filter.Apply(MakeInbound(AuditStatus.Failed, response: oversized));

        Assert.True(result.PayloadTruncated);
        Assert.True(Encoding.UTF8.GetByteCount(result.ResponseSummary!) <= 16_384);
    }

    [Fact]
    public void ApiOutbound_StillUsesDefaultCap_NotInboundMaxBytes()
    {
        // Regression guard: lifting the inbound cap MUST NOT change other
        // channels. An ApiOutbound 100 KB body still hits the 8 KiB cap.
        var opts = new AuditLogOptions();
        var body = new string('a', 100_000);
        var filter = new DefaultAuditPayloadFilter(
            new TestOptionsMonitor<AuditLogOptions>(opts),
            NullLogger<DefaultAuditPayloadFilter>.Instance);

        var evt = new AuditEvent
        {
            EventId = Guid.NewGuid(),
            OccurredAtUtc = DateTime.UtcNow,
            Channel = AuditChannel.ApiOutbound,
            Kind = AuditKind.ApiCall,
            Status = AuditStatus.Delivered,
            RequestSummary = body,
        };
        var result = filter.Apply(evt);

        Assert.True(result.PayloadTruncated);
        Assert.True(Encoding.UTF8.GetByteCount(result.RequestSummary!) <= opts.DefaultCapBytes);
    }
}
```

Verify the `TestOptionsMonitor<T>` helper lives in `tests/ScadaLink.AuditLog.Tests/`. Grep:

```
grep -rn "class TestOptionsMonitor" tests/ScadaLink.AuditLog.Tests
```

If its namespace differs from `ScadaLink.AuditLog.Tests.Configuration`, update the `using` accordingly.

**Step 2: Run tests — confirm they fail**

```
dotnet test tests/ScadaLink.AuditLog.Tests \
  --filter "FullyQualifiedName~InboundChannelCapTests"
```

Expected: the inbound tests fail (filter still applies the 8 KiB cap to ApiInbound). The `ApiOutbound_StillUsesDefaultCap` test SHOULD pass even before the change — that's the regression baseline and stays green after.

**Step 3: Add the channel branch to the filter**

In `DefaultAuditPayloadFilter.cs`, replace the single line:

```csharp
var cap = IsErrorStatus(rawEvent.Status) ? opts.ErrorCapBytes : opts.DefaultCapBytes;
```

with:

```csharp
// Inbound API gets a dedicated, larger ceiling — request/response bodies are
// captured verbatim up to InboundMaxBytes (default 1 MiB) so support can
// replay exactly what the caller sent and what we returned. Other channels
// keep the global 8 KiB / 64 KiB policy.
// See docs/plans/2026-05-23-inbound-api-full-response-audit-design.md.
var cap = rawEvent.Channel == AuditChannel.ApiInbound
    ? opts.InboundMaxBytes
    : (IsErrorStatus(rawEvent.Status) ? opts.ErrorCapBytes : opts.DefaultCapBytes);
```

**Step 4: Run tests — confirm they pass**

```
dotnet test tests/ScadaLink.AuditLog.Tests
```

Expected: all green. The existing `FilterIntegrationTests`, `BodyRegexRedactionTests`, `RedactionSafetyNetTests`, `SqlParamRedactionTests`, etc., MUST stay passing — the change is channel-scoped and the non-inbound cases never see the new branch.

**Step 5: Build the whole solution**

```
dotnet build ScadaLink.slnx
```

Expected: 0 warnings.

**Step 6: Commit**

```
git add src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs \
        tests/ScadaLink.AuditLog.Tests/Payload/InboundChannelCapTests.cs
git commit -m "feat(auditlog): payload filter uses InboundMaxBytes for ApiInbound rows"
```

---

## Task 3: Capture the response body in `AuditWriteMiddleware` (TDD)

**What:** Implement the M5-deferred response-body capture. Wrap `HttpContext.Response.Body` with a buffering `MemoryStream` BEFORE `_next(ctx)`, restore + copy the buffered bytes back to the original stream AFTER the pipeline runs, then read the buffer as UTF-8 into `ResponseSummary` on the audit event. The `AuditEvent.ResponseSummary = null` line goes away.

**Files:**
- Modify: `src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs`.
- Modify: `tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs` — extend.

**Step 1: Write the failing tests**

Append to `AuditWriteMiddlewareTests.cs` (before the closing class brace), keeping the existing `BuildContext` / `CreateMiddleware` helpers:

```csharp
// ---------------------------------------------------------------------
// Response body capture — Audit Log #23 (inbound full-response feature).
// Until the M5-deferred work landed, ResponseSummary was always null.
// These tests pin the new contract: the middleware wraps Response.Body,
// runs the pipeline, copies the buffered bytes back to the real stream,
// and stashes a UTF-8 string copy on ResponseSummary.
// ---------------------------------------------------------------------

[Fact]
public async Task ResponseBody_IsCaptured_OnResponseSummary()
{
    var writer = new RecordingAuditWriter();
    var ctx = BuildContext();
    var responseJson = "{\"result\":42}";
    var mw = CreateMiddleware(async hc =>
    {
        hc.Response.StatusCode = 200;
        hc.Response.ContentType = "application/json";
        await hc.Response.WriteAsync(responseJson);
    }, writer);

    await mw.InvokeAsync(ctx);

    var evt = Assert.Single(writer.Events);
    Assert.Equal(responseJson, evt.ResponseSummary);
}

[Fact]
public async Task ResponseBody_IsForwardedToOriginalStream_DownstreamReadersSeeIt()
{
    // Wrapping the response body must be TRANSPARENT — the real client
    // stream still receives every byte the pipeline wrote.
    var writer = new RecordingAuditWriter();
    var ctx = BuildContext();
    var captured = new MemoryStream();
    ctx.Response.Body = captured;            // simulate the client/test sink

    var responseJson = "{\"ok\":true}";
    var mw = CreateMiddleware(async hc =>
    {
        hc.Response.StatusCode = 200;
        await hc.Response.WriteAsync(responseJson);
    }, writer);

    await mw.InvokeAsync(ctx);

    Assert.Equal(responseJson, Encoding.UTF8.GetString(captured.ToArray()));
}

[Fact]
public async Task ResponseBody_Empty_LeavesResponseSummaryNull()
{
    // No bytes written => null, not empty-string. Mirrors the request-body
    // contract in ReadBufferedRequestBodyAsync.
    var writer = new RecordingAuditWriter();
    var ctx = BuildContext();
    var mw = CreateMiddleware(hc =>
    {
        hc.Response.StatusCode = 204;
        return Task.CompletedTask;
    }, writer);

    await mw.InvokeAsync(ctx);

    var evt = Assert.Single(writer.Events);
    Assert.Null(evt.ResponseSummary);
    Assert.Equal(204, evt.HttpStatus);
}

[Fact]
public async Task ResponseBody_OnHandlerThrow_BodyCapturedUpToTheThrow()
{
    // If the handler writes some bytes then throws, the audit row still
    // surfaces whatever the framework had flushed. The middleware re-throws
    // (audit is best-effort, the request's error path stays authoritative).
    var writer = new RecordingAuditWriter();
    var ctx = BuildContext();
    var boom = new InvalidOperationException("kaboom");
    var mw = CreateMiddleware(async hc =>
    {
        hc.Response.StatusCode = 500;
        await hc.Response.WriteAsync("partial");
        throw boom;
    }, writer);

    var thrown = await Assert.ThrowsAsync<InvalidOperationException>(
        () => mw.InvokeAsync(ctx));
    Assert.Same(boom, thrown);

    var evt = Assert.Single(writer.Events);
    Assert.Equal(AuditStatus.Failed, evt.Status);
    Assert.Equal("partial", evt.ResponseSummary);
}
```

**Step 2: Run tests — confirm they fail**

```
dotnet test tests/ScadaLink.InboundAPI.Tests \
  --filter "FullyQualifiedName~AuditWriteMiddlewareTests"
```

Expected: the four new tests fail (ResponseSummary stays null today). Pre-existing tests still pass (they don't assert ResponseSummary).

**Step 3: Implement response capture in the middleware**

Edit `AuditWriteMiddleware.cs`:

(a) Update the XML doc at the top — remove the "Response body capture is deferred to M5…" paragraph (lines 42-50 in the current file). Replace with:

```csharp
/// <para>
/// <b>Body capture.</b> The request body is buffered via
/// <see cref="HttpRequestRewindExtensions.EnableBuffering(HttpRequest)"/> then
/// rewound so the downstream endpoint handler still sees the full payload. The
/// response body is captured by swapping <see cref="HttpResponse.Body"/> for a
/// <see cref="MemoryStream"/> before the pipeline runs; after the pipeline
/// returns, the buffered bytes are copied to the original stream (transparent
/// to the real client) and read into <see cref="AuditEvent.ResponseSummary"/>.
/// Truncation to the configured inbound ceiling happens in
/// <see cref="ScadaLink.AuditLog.Payload.DefaultAuditPayloadFilter"/>; the
/// middleware itself stores the full buffered content.
/// </para>
```

(b) Rewrite `InvokeAsync` so the response stream is swapped, the buffer is read post-pipeline (in `finally`, even on a thrown handler), and the original stream receives the bytes back:

```csharp
public async Task InvokeAsync(HttpContext ctx)
{
    var sw = Stopwatch.StartNew();
    ctx.Items[InboundExecutionIdItemKey] = Guid.NewGuid();

    // Request body — buffer for both audit + downstream handler.
    ctx.Request.EnableBuffering();
    var requestBody = await ReadBufferedRequestBodyAsync(ctx.Request).ConfigureAwait(false);

    // Response body — swap in a MemoryStream so the pipeline writes are
    // captured. The original Response.Body is restored in the finally block,
    // and the captured bytes are copied back to it so the real client still
    // receives every byte (transparent wrap). The captured string is then
    // available for the audit row.
    var originalResponseBody = ctx.Response.Body;
    using var responseBuffer = new MemoryStream();
    ctx.Response.Body = responseBuffer;

    string? responseBody = null;
    Exception? thrown = null;
    try
    {
        await _next(ctx).ConfigureAwait(false);
    }
    catch (Exception ex)
    {
        thrown = ex;
        throw;
    }
    finally
    {
        sw.Stop();

        // Whatever the handler managed to write — full success, partial
        // success before throwing, or nothing at all — copy back to the
        // original stream and read for audit.
        responseBody = await DrainResponseBufferAsync(responseBuffer, originalResponseBody)
            .ConfigureAwait(false);
        ctx.Response.Body = originalResponseBody;

        EmitInboundAudit(ctx, sw.ElapsedMilliseconds, thrown, requestBody, responseBody);
    }
}
```

(c) Add the new helper (place beside `ReadBufferedRequestBodyAsync`):

```csharp
/// <summary>
/// Copies the bytes buffered in <paramref name="buffer"/> to
/// <paramref name="originalBody"/> (so the real client still receives them)
/// and returns a UTF-8 string copy for <see cref="AuditEvent.ResponseSummary"/>.
/// Returns null when no bytes were written, mirroring the
/// <see cref="ReadBufferedRequestBodyAsync"/> empty-body contract.
/// </summary>
private static async Task<string?> DrainResponseBufferAsync(
    MemoryStream buffer,
    Stream originalBody)
{
    if (buffer.Length == 0)
    {
        return null;
    }

    buffer.Position = 0;
    // Copy first so the client never misses bytes even if the read for audit
    // throws somehow (defensive — MemoryStream.CopyToAsync to a sink shouldn't
    // throw on its own, but the original body may).
    try
    {
        await buffer.CopyToAsync(originalBody).ConfigureAwait(false);
    }
    catch
    {
        // Best-effort: a sink that refuses our copy is the sink's problem;
        // the audit still records what the handler produced. Do NOT rethrow.
    }

    buffer.Position = 0;
    using var reader = new StreamReader(
        buffer,
        Encoding.UTF8,
        detectEncodingFromByteOrderMarks: false,
        bufferSize: 1024,
        leaveOpen: true);
    var content = await reader.ReadToEndAsync().ConfigureAwait(false);
    return string.IsNullOrEmpty(content) ? null : content;
}
```

(d) Change `EmitInboundAudit`'s signature to take the response body, and drop the `ResponseSummary = null` line:

```csharp
private void EmitInboundAudit(
    HttpContext ctx,
    long durationMs,
    Exception? thrown,
    string? requestBody,
    string? responseBody)
{
    // ... unchanged up to the AuditEvent constructor ...

    var evt = new AuditEvent
    {
        // ... existing fields ...
        RequestSummary = requestBody,
        ResponseSummary = responseBody,         // was null in the M4 deliverable
        PayloadTruncated = false,
        Extra = extra,
        ForwardState = null,
    };

    // ... unchanged fire-and-forget write ...
}
```

**Step 4: Run tests — confirm they pass**

```
dotnet test tests/ScadaLink.InboundAPI.Tests
```

Expected: all green, including the four new response-body tests AND every pre-existing middleware test.

**Step 5: Build the whole solution**

```
dotnet build ScadaLink.slnx
```

Expected: 0 warnings.

**Step 6: Commit**

```
git add src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs \
        tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs
git commit -m "feat(inboundapi): AuditWriteMiddleware captures response body on ApiInbound audit rows"
```

---

## Task 4: Update `Component-AuditLog.md`

**What:** Reflect the inbound carve-out in the requirements doc — schema row descriptions + Payload Capture Policy.

**Files:**
- Modify: `docs/requirements/Component-AuditLog.md`.

**Edits (all in-place — no copies):**

1. Schema table — find the `RequestSummary` row. Change its `Description` cell from:
   > Truncated request payload (configurable cap). Headers redacted.
   to:
   > Truncated request payload (configurable cap). Headers redacted. For `Channel = ApiInbound`, captured in full up to `AuditLog:InboundMaxBytes` (default 1 MiB) — see Payload Capture Policy.

2. Schema table — `ResponseSummary` row. Change its `Description` cell from:
   > Truncated response payload. Full on errors.
   to:
   > Truncated response payload. For `Channel = ApiInbound`, captured in full up to `AuditLog:InboundMaxBytes` (default 1 MiB). For other channels, capped at `DefaultCapBytes` by default and `ErrorCapBytes` on error rows.

3. **Payload Capture Policy** section — after the existing **Default cap** bullet, insert:

   > - **Inbound API exception.** For `Channel = ApiInbound`, `RequestSummary` and `ResponseSummary` are captured in full up to a per-body hard ceiling of 1 MiB (configurable via `AuditLog:InboundMaxBytes`; default 1 048 576 bytes; min 8 192; max 16 777 216). The 8 KiB / 64 KiB default/error caps that apply to other channels do not apply here. `PayloadTruncated = 1` is set only when the inbound ceiling is hit — verbatim capture is the normal case. The ceiling applies independently to each body. Header redaction and per-target body redactors still run before persistence.

**Verify:** `git diff docs/requirements/Component-AuditLog.md` — three edits, no other lines touched.

**Commit:**

```
git add docs/requirements/Component-AuditLog.md
git commit -m "docs(audit): schema + Payload Capture Policy note inbound full-body carve-out"
```

---

## Task 5: Update `Component-InboundAPI.md`

**What:** Update the two existing references that say "truncated request/response bodies per the Audit Log capture policy" to reflect the new wording.

**Files:**
- Modify: `docs/requirements/Component-InboundAPI.md`.

**Edits:**

1. Around line 119 (the inbound-audit description in §Operational Audit / Logging) — change:
   > Every request — success or failure — emits one `ApiInbound.Completed` row to `ICentralAuditWriter` from request middleware before the HTTP response is flushed. The row captures the API key **name** (never the key material), remote IP, user-agent, response status, duration, and truncated request/response bodies per the Audit Log capture policy (see Component-AuditLog.md, Payload Capture Policy).
   to:
   > Every request — success or failure — emits one `ApiInbound.Completed` row to `ICentralAuditWriter` from request middleware before the HTTP response is flushed. The row captures the API key **name** (never the key material), remote IP, user-agent, response status, duration, and the request/response bodies. Bodies are captured in full up to `AuditLog:InboundMaxBytes` (default 1 MiB); `PayloadTruncated = 1` only when that ceiling is hit. Header redaction and per-target body redactors still apply (see Component-AuditLog.md, Payload Capture Policy).

2. Around line 202 (Dependencies → Audit Log) — change:
   > **Audit Log (#23)**: Every inbound API request emits an `ApiInbound.Completed` row via `ICentralAuditWriter` from request middleware (non-blocking for the HTTP response). Payload truncation/redaction follows the Audit Log Payload Capture Policy.
   to:
   > **Audit Log (#23)**: Every inbound API request emits an `ApiInbound.Completed` row via `ICentralAuditWriter` from request middleware (non-blocking for the HTTP response). Request and response bodies are captured in full up to `AuditLog:InboundMaxBytes` (default 1 MiB) per the Audit Log Payload Capture Policy; redaction (headers + per-target body redactors) still applies before persistence.

**Verify:**

```
grep -nE "truncated request/response|InboundMaxBytes" docs/requirements/Component-InboundAPI.md
```

Expected: no "truncated request/response" hits remain; two "InboundMaxBytes" hits land on the updated lines.

**Commit:**

```
git add docs/requirements/Component-InboundAPI.md
git commit -m "docs(inboundapi): note request/response bodies captured in full to InboundMaxBytes"
```

---

## Task 6: Final solution build + full test run + branch summary

**What:** Confirm the cumulative change is green end-to-end and summarise the branch.

**Steps:**

1. `dotnet build ScadaLink.slnx` from the repo root — expect 0 warnings, 0 errors.

2. Run the affected test projects:

   ```
   dotnet test tests/ScadaLink.AuditLog.Tests
   dotnet test tests/ScadaLink.InboundAPI.Tests
   ```

   Expect both green. (The wider solution test run is optional but cheap — `dotnet test ScadaLink.slnx` if you want full coverage.)

3. `git log --oneline main..HEAD` — expect exactly five commits:
   - feat(auditlog): add AuditLog:InboundMaxBytes option …
   - feat(auditlog): payload filter uses InboundMaxBytes for ApiInbound rows
   - feat(inboundapi): AuditWriteMiddleware captures response body on ApiInbound audit rows
   - docs(audit): schema + Payload Capture Policy note inbound full-body carve-out
   - docs(inboundapi): note request/response bodies captured in full to InboundMaxBytes

4. `git status --short` — expect a clean tree (no uncommitted files; pre-existing uncommitted files from `git status` at session start may still be present and are unrelated to this work — leave them).

**Acceptance:**
- All acceptance criteria in `docs/plans/2026-05-23-inbound-api-full-response-audit-design.md` met.
- Solution builds clean.
- All targeted tests pass.
- Five commits on the feature branch, no commits on `main`, branch not pushed.

**Commit:** none. Do not merge or push — that is the user's call.

---

## Notes for the executor

- The `ResponseSummary = null` comment in `AuditWriteMiddleware.cs` (line ~191 today) is the smoking-gun: response capture was always intended, just deferred. The design doc explicitly authorises closing that gap.
- The `TestOptionsMonitor<T>` helper is already used by `AuditLogOptionsBindingTests.cs` — reuse it; do not introduce a second one. If its public location moves between when this plan was written and execution, `grep -rn "class TestOptionsMonitor" tests/` and adjust the `using`.
- `appsettings.json` files in `docker/central-node-a` and `docker/central-node-b` do not currently override `AuditLog:*` — leave them alone. The 1 MiB default takes effect automatically from `AuditLogOptions`.
- No EF migration is needed — schema is unchanged (`nvarchar(max)` already).
- No new health metric — `PayloadTruncated = 1` carries the ceiling-hit signal. The design doc explicitly defers a dedicated `AuditInboundCeilingHits` counter.
