fix(site-runtime): Flow Debugger inline trace copy is redacted and byte-capped at the collector (arch-review F1, site half)

Every emit site hands FlowTraceCollector the same AuditEvent it hands the
writer, but redaction and payload capping lived inside the writers, which
return a NEW filtered record — so the inline copy that rides
ScriptCallResult.TraceEvents / RouteToCallResponse.TraceEvents was the RAW
event: no per-target SQL-parameter redaction, no header scrubbing, no 8 KB /
64 KB truncation. Live and Replay of the same execution could therefore show
different payloads, and the 500-event-only cap left the reply free to breach
the 4 MB gRPC receive limit — which would fail a real call, since the same
reply carries the script's return value.

The collector now applies the host IAuditRedactor itself (outside the lock;
the writers keep their own idempotent Apply) and enforces a byte budget
(DefaultMaxBytes = 1 MB over the redacted DetailsJson) alongside the event
cap, flagging Truncated when either trips. It moves to Commons
(Types/Debugging) so the central FlowDebugRunner can share it.

The redactor is a singleton, so ScriptRunLauncher resolves it from the ROOT
provider and keeps constructing the collector before the try — the traced
ScriptRun row is emitted from the catch blocks too, including the
shed-at-dequeue path that throws before the DI scope exists.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-06 14:15:38 -04:00
parent cccef7b2f4
commit e79452d2de
4 changed files with 138 additions and 11 deletions
@@ -209,6 +209,39 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
Assert.False(ctx.IsTraced);
}
/// <summary>
/// Arch-review F1: the collector applies the host redactor, so the inline copy the reply
/// carries is the FILTERED record — while the writer still receives the raw event and
/// redacts it itself (redaction is idempotent, so the stored row is identical).
/// </summary>
[Fact]
public async Task SetAttribute_WhenTraced_InlineCopyIsRedacted_WriterStillSeesTheRawEvent()
{
var probe = CreateTestProbe();
var writer = new CapturingAuditWriter();
var collector = new FlowTraceCollector(redactor: new UpperRedactor());
var ctx = CreateContext(probe.Ref, writer, traced: true, collector);
var call = ctx.SetAttribute("Setpoint", "42");
var cmd = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
probe.Reply(new SetStaticAttributeResponse(cmd.CorrelationId, InstanceName, "Setpoint", true, null, DateTimeOffset.UtcNow));
await call;
var written = Assert.Single(writer.Events);
var collected = Assert.Single(collector.Complete());
Assert.NotSame(written, collected);
Assert.Contains("\"channel\":\"ScriptTrace\"", written.DetailsJson);
Assert.NotEqual(written.DetailsJson, collected.DetailsJson);
Assert.Equal(written.DetailsJson!.ToUpperInvariant(), collected.DetailsJson);
}
/// <summary>Upper-cases <c>DetailsJson</c> so a test can see the filtered copy land.</summary>
private sealed class UpperRedactor : IAuditRedactor
{
public AuditEvent Apply(AuditEvent rawEvent)
=> rawEvent with { DetailsJson = rawEvent.DetailsJson?.ToUpperInvariant() };
}
[Fact]
public async Task SetAttribute_WhenTraced_FailedWrite_EmitsFailedRow_ThenThrows()
{