All 35 findings fixed in 544a6dd and marked Status: Resolved with resolution
notes. README regenerated: 0 pending / 35 total across 6 libraries.
14 KiB
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:
- The
IAuditRedactor"never throws / over-redacts on failure" contract — no test drivesTruncatingAuditRedactor.Applyinto itscatchbranch, so the over-redaction fallback (and the asymmetry flagged in Audit-002) is unverified. CompositeAuditWriterboundary inputs — no test covers an empty writer list (should be a no-op) or a list containing anullwriter (currently swallowed via the barecatch, 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).