# 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 ` — 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 /// /// Per-body byte ceiling applied to and /// for 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 /// docs/plans/2026-05-23-inbound-api-full-response-audit-design.md. /// 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; /// /// 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. /// 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(opts), NullLogger.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(opts), NullLogger.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(opts), NullLogger.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(opts), NullLogger.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` 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( () => 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 /// /// Body capture. The request body is buffered via /// then /// rewound so the downstream endpoint handler still sees the full payload. The /// response body is captured by swapping for a /// 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 . /// Truncation to the configured inbound ceiling happens in /// ; the /// middleware itself stores the full buffered content. /// ``` (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 /// /// Copies the bytes buffered in to /// (so the real client still receives them) /// and returns a UTF-8 string copy for . /// Returns null when no bytes were written, mirroring the /// empty-body contract. /// private static async Task 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` 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.