# 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 `<GenerateDocumentationFile>true</GenerateDocumentationFile>` (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 `<GenerateDocumentationFile>true</GenerateDocumentationFile>` 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).
