# Code Review — Audit | Field | Value | |-------|-------| | Library | `ZB.MOM.WW.Audit/` | | Packages | `ZB.MOM.WW.Audit` | | Component spec | `components/audit/spec/SPEC.md` | | Shared contract | `components/audit/shared-contract/ZB.MOM.WW.Audit.md` | | Status | Reviewed | | Last reviewed | 2026-06-01 | | Reviewer | Claude (automated baseline) | | Commit reviewed | `5f75cd4` | | Open findings | 0 | ## Summary `ZB.MOM.WW.Audit` is a small, clean, well-scoped library: one canonical `AuditEvent` sealed record, a 3-value `AuditOutcome` enum, two narrow seams (`IAuditWriter`, `IAuditRedactor`), five shipped helpers, and a single `AddZbAudit` registration. The record matches `EVENT-MODEL.md` field-for-field (names, types, required/optional split, and the `OccurredAtUtc` UTC-normalization contract — including value-equality on the normalized instant). The dependency footprint is exactly as specified: the only non-BCL reference is `Microsoft.Extensions.DependencyInjection.Abstractions` (verified against the built `deps.json`), with no Akka/EF/SQLite/Serilog/Telemetry leakage. Guard clauses are present on every public entry point, `RedactingAuditWriter` guarantees redaction runs before the inner writer (no unredacted-PII leak), and `CompositeAuditWriter` isolates per-writer failures so a throwing writer does not lose downstream events. 19 tests cover the happy paths and the most important contract guards. No Critical or High findings. The five findings are all Medium/Low and concern: a cancellation-propagation tension with the "never throw to the caller" writer contract (Audit-001), a partial over-redaction path in `TruncatingAuditRedactor` (Audit-002), mutable options on a singleton redactor (Audit-003), un-emitted XML docs (Audit-004, a family-wide baseline pattern), and a couple of missing edge-case tests (Audit-005). ## Checklist coverage | # | Category | Examined | Notes | |---|----------|----------|-------| | 1 | Correctness & logic bugs | ☑ | `Truncate` boundary logic correct for sane configs; marker-longer-than-max clips safely. Partial over-redaction edge under negative max → Audit-002. | | 2 | Public API surface & compatibility | ☑ | Minimal, intentional surface; `sealed` records/classes; nullable annotations correct; no internal-type leakage. Options type is a mutable class, not the record the contract describes → Audit-003. | | 3 | Concurrency & thread safety | ☑ | `AuditEvent` immutable (readonly backing field); singletons (`NoOp`/`Null`) stateless; composite snapshots its writers via `ToArray()`. Mutable singleton options noted in Audit-003. | | 4 | Error handling & resilience | ☑ | Guard clauses on all entry points; composite swallows writer faults. OCE re-throw vs writer contract → Audit-001; partial over-redact → Audit-002. | | 5 | Security & secret handling | ☑ | `RedactingAuditWriter` applies the redactor strictly before the inner writer — no unredacted-event leak. `Actor` kept a plain string (no Auth coupling), as specified. No secrets in code/messages. | | 6 | Performance & resource management | ☑ | No `IDisposable` to manage; no hot-path allocations beyond a single `with`-copy per redact; DI registration is trivial. No issues found. | | 7 | Spec & shared-contract adherence | ☑ | `AuditEvent` + `AuditOutcome` match `EVENT-MODEL.md` exactly; `AddZbAudit` uses `TryAdd` as specified. OCE re-throw deviates from the §1 writer hard rule → Audit-001. | | 8 | Packaging, dependencies & project layout | ☑ | Sole non-BCL dep confirmed via `deps.json`; net10.0; correct PackageId/version (0.1.0). XML docs not packed → Audit-004. | | 9 | Testing coverage | ☑ | 19 tests; public contract exercised (round-trip, UTC normalization, fan-out, cancellation, redaction ordering). Missing never-throw/over-redact and null-writer cases → Audit-005. | | 10 | Documentation & XML docs | ☑ | XML docs present and accurate on the whole public surface; README accurate. Docs not emitted to a file → Audit-004. | ## Findings ### Audit-001 — `CompositeAuditWriter` re-throws `OperationCanceledException` to the caller, contradicting the "must not throw" writer contract | | | |--|--| | Severity | Medium | | Category | Error handling & resilience | | Status | Resolved | | Location | `ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/CompositeAuditWriter.cs:24` | **Description** `IAuditWriter`'s hard contract is unambiguous: "Best-effort; must not throw to the caller" (`IAuditWriter.cs:11`), and SPEC §1 states "`WriteAsync` MUST NOT throw to the caller … a failed write must never abort the user-facing action it is recording." The shared contract further says a cancellation "does not constitute a contract violation" and the implementation "may choose to complete a partially-written event anyway" — it permits ignoring the token, not throwing on it. `CompositeAuditWriter.WriteAsync` re-throws `OperationCanceledException` (`catch (OperationCanceledException) { throw; }`, line 24). The very common call shape is to pass the ambient request token, e.g. `writer.WriteAsync(evt, httpContext.RequestAborted)`. When a client disconnects mid-request, the inner writer observes the cancelled token, throws OCE, and the composite propagates it straight into the user-facing action — exactly the abort the seam promises never to cause. This also directly contradicts the helper's own XML doc, which claims "the fan-out drains and the caller is never aborted" (`CompositeAuditWriter.cs:4`). Because the composite is the recommended way to wire multiple sinks, every consumer that uses it inherits this behaviour; the only workaround is to not pass a request-scoped token, which is non-obvious. Note this is a deliberate, tested choice (`CompositeAuditWriterTests.cs:41`), so it is a design/contract conflict rather than an oversight — but the conflict is real: the writer contract says "never throw," the composite says "drains, never aborts," and the code throws on cancellation. **Recommendation** Reconcile the contract and the implementation. Either (a) honour the "never throw" rule literally — swallow OCE like any other writer failure (drop the remaining writers or continue, but do not propagate), and update the XML doc accordingly; or (b) if cancellation propagation is genuinely wanted, amend the `IAuditWriter` SPEC/shared-contract to carve out OCE explicitly and add a doc warning that consumers must not pass a request-scoped token they cannot afford to see surface. Option (a) is the lower-blast-radius choice and matches the seam's stated intent. **Resolution** Resolved in `544a6dd`, 2026-06-01 — `CompositeAuditWriter.WriteAsync` now swallows `OperationCanceledException` like any other writer failure (single bare `catch`), so cancellation never surfaces to the caller; XML doc and the cancellation test updated to assert non-propagation. ### Audit-002 — `TruncatingAuditRedactor` over-redaction is partial: the catch path scrubs only `DetailsJson`, leaving `Target` unredacted | | | |--|--| | Severity | Low | | Category | Error handling & resilience | | Status | Resolved | | Location | `ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactor.cs:27-31`, `:34-40` | **Description** The redactor's over-redact fallback returns `rawEvent with { DetailsJson = null }` (line 30). That scrubs only `DetailsJson` — `Target`, which this redactor is also responsible for capping, is carried through untouched. The spec's "over-redact" rule is "returns a strictly safer event" (SPEC §2); leaving an oversized/sensitive `Target` in place is not strictly safer. This becomes reachable under misconfiguration. `Truncate(value, max)` does `marker[..max]` when `marker.Length >= max` (line 38). For a negative `MaxTargetLength` or `MaxDetailsJsonLength`, that slice throws `ArgumentOutOfRangeException`, the `Apply` catch fires, and the result keeps the **original, untruncated `Target`** while only nulling `DetailsJson`. So a config bug that should fail safe instead leaves the `Target` field unredacted. (`max == 0` is handled correctly — `marker[..0]` is `""`.) The redactor never throws, so the "must not throw" half of the contract holds; it is the "strictly safer" half that is violated. **Recommendation** Make the fallback strictly safer across the fields this redactor owns, e.g. `return rawEvent with { DetailsJson = null, Target = null };` in the catch. Additionally, guard against nonsensical caps at the boundary — clamp `max` to `>= 0` in `Truncate` (treat negative as 0), or validate the options in the constructor — so the catch path is only ever hit by truly unexpected failures rather than a predictable negative-length misconfiguration. **Resolution** Resolved in `544a6dd`, 2026-06-01 — the over-redact catch now returns `rawEvent with { DetailsJson = null, Target = null }` (strictly safer), and `Truncate` clamps a negative `max` to 0 so a negative-length misconfiguration fails safe instead of throwing; new tests pin both behaviours. ### Audit-003 — `TruncatingAuditRedactorOptions` is a mutable class, not the immutable "options record" the contract describes | | | |--|--| | Severity | Low | | Category | Public API surface & compatibility | | Status | Resolved | | Location | `ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactorOptions.cs:4-12` | **Description** The shared contract lists `TruncatingAuditRedactorOptions` as an "Options record" (`shared-contract/ZB.MOM.WW.Audit.md:93`), but it is implemented as a `sealed class` with three mutable `get; set;` auto-properties. `TruncatingAuditRedactor` captures the instance by reference (`_options = options ?? new(...)`, `TruncatingAuditRedactor.cs:14`) and the redactor is typically registered as a singleton. Because the options object stays mutable, a caller (or any other holder of the same reference) can change `MaxDetailsJsonLength` / `MaxTargetLength` / `TruncationMarker` after the redactor is built, changing redaction behaviour at runtime under a concurrent host with no synchronization. This is a (minor) thread-safety footgun and a drift from both the documented "record" shape and the library's otherwise consistent immutability posture (`AuditEvent` is a record with init-only members). **Recommendation** Make the options immutable: convert to a `sealed record` with `init`-only properties (matching the contract wording), or have the redactor defensively snapshot the three values into private readonly fields at construction so post-construction mutation cannot affect it. **Resolution** Resolved in `544a6dd`, 2026-06-01 — `TruncatingAuditRedactorOptions` is now a `sealed record` with `init`-only properties, matching the contract's "options record" and removing the post-construction mutation footgun on the singleton redactor. ### Audit-004 — XML documentation is authored but not emitted, so IntelliSense docs do not ship to consumers | | | |--|--| | Severity | Low | | Category | Documentation & XML docs | | Status | Resolved | | Location | `ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.csproj:1-18`, `ZB.MOM.WW.Audit/Directory.Build.props:1-10` | **Description** Every public type and member carries accurate XML doc comments, but neither the project nor `Directory.Build.props` sets `GenerateDocumentationFile`. As a result no `ZB.MOM.WW.Audit.xml` is produced or packed alongside the DLL (confirmed: no XML doc file in `bin/`), so consuming apps get no IntelliSense/tooltip documentation from the package. For a shared library where, per the review process, "public docs matter," the authored docs are effectively invisible to consumers. (This is a family-wide baseline gap — none of the sibling `ZB.MOM.WW.*` libraries set the flag either — so it is a baseline issue rather than an Audit-specific regression, hence Low.) **Recommendation** Set `true` (ideally once in the shared `Directory.Build.props` so it applies across the family). Because the public surface is fully documented, no CS1591 "missing XML comment" warnings should appear. **Resolution** Resolved in `544a6dd`, 2026-06-01 — set `true` in the packable `ZB.MOM.WW.Audit.csproj`; `ZB.MOM.WW.Audit.xml` now builds with zero CS1591 warnings and packs into the nupkg under `lib/net10.0/`. ### Audit-005 — Missing edge-case tests for the redactor never-throw/over-redact contract and composite null/empty handling | | | |--|--| | Severity | Low | | Category | Testing coverage | | Status | Resolved | | Location | `ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/TruncatingAuditRedactorTests.cs`, `.../CompositeAuditWriterTests.cs` | **Description** The 19 tests cover the happy paths and the key guards (UTC normalization + equality, fan-out, cancellation propagation, redaction ordering, marker-longer-than-max). Two contract-critical behaviours are untested: 1. The `IAuditRedactor` "never throws / over-redacts on failure" contract — no test drives `TruncatingAuditRedactor.Apply` into its `catch` branch, so the over-redaction fallback (and the asymmetry flagged in Audit-002) is unverified. 2. `CompositeAuditWriter` boundary inputs — no test covers an empty writer list (should be a no-op) or a list containing a `null` writer (currently swallowed via the bare `catch`, silently dropping that sink — behaviour that should be pinned by a test). Pinning these would have surfaced Audit-002 and would lock the documented never-throw guarantee. **Recommendation** Add: (a) a redactor test that forces an internal failure (e.g. a misconfigured cap) and asserts `Apply` returns a strictly-safer event without throwing; (b) a composite test for the empty-list no-op; and (c) a composite test documenting the null-entry behaviour (whether swallowed or guarded — pair with the chosen fix for the null case). **Resolution** Resolved in `544a6dd`, 2026-06-01 — added four tests: redactor over-redact catch branch scrubs both fields, negative-max clamp, and `CompositeAuditWriter` empty-list no-op + null-writer-entry swallow (19 → 23 tests).