# Audit — current state: ScadaBridge Repo: `~/Desktop/ScadaBridge`. Stack: .NET 10, Akka.NET; solution `ZB.MOM.WW.ScadaBridge.slnx`. Audit code centers on the dedicated `ZB.MOM.WW.ScadaBridge.AuditLog` project, with the shared record + seams living in `ZB.MOM.WW.ScadaBridge.Commons`. All paths relative to repo root. Verified 2026-06-01. **By far the largest audit implementation in the family** — a full who-did-what pipeline across a site SQLite hot-path and a central MS SQL store, with forwarding, reconciliation, purge, partition maintenance, redaction, CLI export, hash-chain verify (v1 stub), and a Blazor UI. **Key finding: ScadaBridge is already at the target.** It already has an `IAuditWriter` best-effort seam (near-identical to the canonical contract) and an `IAuditPayloadFilter` redaction seam (= the library's `IAuditRedactor`, just renamed). Adoption is *align, don't replace* — mostly naming alignment; the enormous transport/storage/CLI/UI stays bespoke. ## 1. How it works today ### The record — `AuditEvent` (~25 fields) `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Audit/AuditEvent.cs:22` — a `sealed record`, append-only, "single source of truth for AuditLog (#23) rows." Far richer than the canonical 10-field event. Notable fields: - Identity / correlation: `EventId` (idempotency key, `:25`), `CorrelationId` (per-op lifecycle, `:68`), `ExecutionId` (per-run, `:75`), `ParentExecutionId` (spawner link, `:82`). - Classification: `Channel` (`:62`), `Kind` (`:65`), `Status` (`:109`) — the domain enums (below). - Provenance: `SourceSiteId` (`:85`), `SourceNode` (`:94`, stamped from `INodeIdentityProvider`), `SourceInstanceId` (`:97`), `SourceScript` (`:100`), `Actor` (`:103`), `Target` (`:106`). - Outcome detail: `HttpStatus` (`:112`), `DurationMs` (`:115`), `ErrorMessage` (`:118`), `ErrorDetail` (`:121`). - Payload: `RequestSummary` / `ResponseSummary` (truncated+redacted, `:124`/`:127`), `PayloadTruncated` (`:130`), `Extra` (free-form JSON, `:133`). - Lifecycle plumbing: `IngestedAtUtc` (null on site, stamped at central ingest, `:52`), `ForwardState` (site-only, null on central, `:136`). **UTC-forcing init-setters.** `OccurredAtUtc` (`:39`) and `IngestedAtUtc` (`:52`) keep a backing field and call `DateTime.SpecifyKind(value, DateTimeKind.Utc)` on assignment, so a value built from a literal or rehydrated from a SQL Server `datetime2` column (which strips `Kind` on the wire) cannot leak downstream as `Unspecified`/local. The record uses `DateTime` (not `DateTimeOffset`) deliberately, to match the partitioned `datetime2` column shape (`:9-21`). ### Domain vocabulary — four enums `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/`: - `AuditChannel.cs:7` — trust boundary crossed: `ApiOutbound`, `DbOutbound`, `Notification`, `ApiInbound`. - `AuditKind.cs:8` — specific event within a channel: `ApiCall`, `ApiCallCached`, `DbWrite`, `DbWriteCached`, `NotifySend`, `NotifyDeliver`, `InboundRequest`, `InboundAuthFailure`, `CachedSubmit`, `CachedResolve`. Cached variants emit multiple rows per operation. - `AuditStatus.cs:8` — lifecycle status of the row: `Submitted`, `Forwarded`, `Attempted`, `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`. - `AuditForwardState.cs:9` — site-local forwarding state (central rows leave null): `Pending`, `Forwarded`, `Reconciled`. The site retention purge MUST NOT drop a `Pending` row. ### The writer seam — `IAuditWriter` (best-effort, never aborts the action) `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/IAuditWriter.cs:10` — boundary-side abstraction: `Task WriteAsync(AuditEvent evt, CancellationToken ct = default)` (`:18`). The contract is explicit and matches the canonical seam almost word-for-word: **"Failures must NEVER abort the user-facing action"** (`:8`), best-effort, "implementations must swallow/log internal failures rather than propagating them to the calling boundary code" (`:13-14`). ### The redaction seam — `IAuditPayloadFilter` (pure, never throws) `src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/IAuditPayloadFilter.cs:22` — `AuditEvent Apply( AuditEvent rawEvent)` (`:30`). Filters an event between construction and persistence: truncates oversized payloads, redacts headers/body/SQL params, sets `PayloadTruncated`. **Pure function** returning a filtered COPY via `with` expressions, and **MUST NOT throw** — on internal failure it over-redacts and increments the `AuditRedactionFailure` health metric (`:11-20`, `:26-28`). This is exactly the canonical `IAuditRedactor` under a different name. Two implementations: `DefaultAuditPayloadFilter.cs:56` (full truncation + header/body/SQL redaction with live options) and `SafeDefaultAuditPayloadFilter.cs:19` (always-safe fallback — header-only redaction, over-redacts on parse failure, `:42-59`). ### Transport / storage / pipeline — stays per-project The `ZB.MOM.WW.ScadaBridge.AuditLog` project is split into `Site/`, `Central/`, `Payload/`, and `Configuration/`. This is the bespoke half and is **not** a candidate for extraction; cited here only to show the scale around the common core: - **Site hot-path:** `Site/SqliteAuditWriter.cs:32` (`IAuditWriter` over an owned `SqliteConnection` fed by a bounded `Channel` drained on a background task, so script-thread callers never block on disk I/O; first-write-wins on duplicate `EventId`). `Site/FallbackAuditWriter.cs:28` composes the SQLite writer with a drop-oldest `RingBufferFallback` so a primary failure never bubbles out. `Site/Telemetry/` forwards rows to central over Akka `ClusterClient`. - **Central ingest/store:** `Central/CentralAuditWriter.cs:40` (`ICentralAuditWriter`, direct MS SQL write for central-originated events, per-call EF scope, idempotent `InsertIfNotExistsAsync`, swallows every exception per "alog.md §13"). `Central/AuditLogIngestActor.cs:46` batches site telemetry; `Central/SiteAuditReconciliationActor.cs:68` periodically pulls to catch dropped forwards; `Central/AuditLogPurgeActor.cs:58` enforces retention; `Central/AuditLogPartitionMaintenanceService.cs:55` manages the partitioned table. - **CLI:** `CLI/Commands/AuditCommands.cs:12` builds `export` (`:137`, formats `csv`/`jsonl`/`parquet`) and `verify-chain` (`:226`). Hash-chain verify is currently a **v1 no-op stub** — `CLI/Commands/AuditVerifyChainHelpers.cs:6-10` ("v1 is a no-op"). - **UI:** Blazor pages under `CentralUI/Components/Pages/Audit/` (e.g. `AuditLogPage.razor:1`, gated by `[Authorize(Policy = AuthorizationPolicies.OperationalAudit)]`) plus drill-down components in `CentralUI/Components/Audit/`. - **Wiring:** `AuditLog/ServiceCollectionExtensions.cs:59` `AddAuditLog(...)`, `:316` `AddAuditLogCentralMaintenance(...)`. ## 2. Mapping to the canonical record Target (`ZB.MOM.WW.Audit`, being built): `record AuditEvent { Guid EventId; DateTimeOffset OccurredAtUtc; string Actor; string Action; AuditOutcome Outcome; string? Category; string? Target; string? SourceNode; Guid? CorrelationId; string? DetailsJson; }`. ScadaBridge's record is a strict superset — the canonical fields map directly; the rich extras collapse into `DetailsJson`. | Canonical field | ScadaBridge source | Notes | |---|---|---| | `EventId` (Guid) | `AuditEvent.EventId` | Direct; same idempotency-key role. | | `OccurredAtUtc` (DateTimeOffset) | `AuditEvent.OccurredAtUtc` (`DateTime`, UTC-forced) | Type bridge `DateTime`(Utc)↔`DateTimeOffset`; semantics identical. | | `Actor` (string) | `AuditEvent.Actor` (nullable) | Direct; ScadaBridge allows null (system-originated rows). | | `Action` (string) | `AuditEvent.Kind` (+`Channel`) | Derive a stable action string, e.g. `{Channel}.{Kind}` (`ApiOutbound.ApiCall`). | | `Outcome` (Success/Failure/Denied) | `AuditEvent.Status` | `Delivered`→Success; `Failed`/`Parked`/`Discarded`→Failure; `InboundAuthFailure`(Kind)→Denied; in-flight `Submitted`/`Forwarded`/`Attempted` collapse to the last-known terminal state when projecting. | | `Category` (string?) | `AuditEvent.Channel` | The coarse bucket; pairs with `Action` above. | | `Target` (string?) | `AuditEvent.Target` | Direct. | | `SourceNode` (string?) | `AuditEvent.SourceNode` | Direct (`node-a`/`central-b`/…). | | `CorrelationId` (Guid?) | `AuditEvent.CorrelationId` | Direct (per-op lifecycle id). | | `DetailsJson` (string?) | `ExecutionId`, `ParentExecutionId`, `SourceSiteId`, `SourceInstanceId`, `SourceScript`, `HttpStatus`, `DurationMs`, `ErrorMessage`, `ErrorDetail`, `RequestSummary`, `ResponseSummary`, `PayloadTruncated`, `Extra`, `IngestedAtUtc`, `ForwardState` | The ~15 rich/plumbing fields serialize into the canonical `DetailsJson` extension. | The canonical record is a lossy *projection* of ScadaBridge's — fine for cross-project reporting, but ScadaBridge keeps its full record as the storage shape (the partitioned SQL schema, forwarding state, and reconciliation all depend on the extra columns). ## 3. Adoption plan → `ZB.MOM.WW.Audit` **Posture: align, don't replace.** ScadaBridge is the reference implementation the shared library is being extracted *from*; it already has both seams. Adoption is mostly renaming and contract-confirmation, with a deliberately small touched surface and a large blast radius if done carelessly. **Priority: LOW. Blast radius: HIGH.** **Align (small, naming-level):** - **Rename the redaction seam to match the contract.** `IAuditPayloadFilter` → adopt `ZB.MOM.WW.Audit.IAuditRedactor` (`AuditEvent Apply(AuditEvent)` — identical signature and pure/never-throws contract). Either alias `IAuditPayloadFilter : IAuditRedactor` during transition or rename outright; `DefaultAuditPayloadFilter` / `SafeDefaultAuditPayloadFilter` implement it unchanged. See [`../../shared-contract/`](../../shared-contract/). - **Confirm the writer contract matches.** `IAuditWriter.WriteAsync(AuditEvent, CancellationToken = default)` is already byte-for-byte the canonical signature, and the "never abort the user-facing action" wording matches. The only delta is the **record type**: the library's `IAuditWriter` is typed on the *canonical* 10-field `AuditEvent`, while ScadaBridge's is typed on its ~25-field record. Resolve by either (a) keeping ScadaBridge's writer on its own rich record and adopting only the library's *interface name + outcome enum*, or (b) having the shared seam be generic over the event type. **Recommended: (a)** — adopt the canonical `AuditOutcome` enum and the interface naming, but keep the bespoke `AuditEvent` as ScadaBridge's storage record, since the whole transport/partition/forwarding layer is built on its extra columns. (Best-practice fit: this is the minimal-coupling option — share the contract, not the schema.) **Keep bespoke (the large, untouched majority):** - The entire `Site/` (SQLite hot-path + ring-buffer fallback + telemetry forwarder) and `Central/` (ingest / reconcile / purge / partition maintenance) pipeline. - The `AuditEvent` rich record itself, the four domain enums (`AuditChannel`/`AuditKind`/ `AuditStatus`/`AuditForwardState`), CLI `export`/`verify-chain`, and the Blazor audit UI. - The redaction *policy* (`DefaultAuditPayloadFilter` options, per-target overrides) — only the interface name is shared, not the implementation. **Net:** ScadaBridge converges by renaming one interface and adopting the canonical `AuditOutcome` enum + the `Kind`/`Channel`→`Action`/`Category` and `…`→`DetailsJson` projection for any cross-project reporting. No transport, storage, CLI, or UI is replaced. Sequencing and the cross-project gap list live in [`../../GAPS.md`](../../GAPS.md); the canonical target is [`../../spec/SPEC.md`](../../spec/SPEC.md).