# 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<T>` 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).
