# Canonical event model (standardized)

Status: **Standardized**. The org-wide audit record + outcome enum every sister project maps onto.
This is the reference companion to [`SPEC.md`](SPEC.md) (mirroring auth's `CANONICAL-ROLES.md` /
theme's `DESIGN-TOKENS.md`): the field-by-field canonical record, the `AuditOutcome` definition with
which app states map onto each value, and the full per-project mapping table. The shared library
defines exactly this record; each project **projects its native record onto it** at the seam.

## The canonical record

```csharp
namespace ZB.MOM.WW.Audit;

public sealed record AuditEvent
{
    // REQUIRED core — who / what / when / outcome
    public required Guid           EventId       { get; init; }  // idempotency key
    public required DateTimeOffset OccurredAtUtc { get; init; }  // normalized to UTC
    public required string         Actor         { get; init; }  // who — = ZB.MOM.WW.Auth principal at adoption
    public required string         Action        { get; init; }  // what — verb / event-type string
    public required AuditOutcome   Outcome       { get; init; }  // Success | Failure | Denied

    // OPTIONAL common
    public string? Category      { get; init; }  // subsystem / grouping bucket
    public string? Target        { get; init; }  // on-what (resource / method / connection)
    public string? SourceNode    { get; init; }  // emitting logical node / host
    public Guid?   CorrelationId { get; init; }  // join to originating request / workflow

    // EXTENSION — everything project-specific, as JSON
    public string? DetailsJson   { get; init; }
}

public enum AuditOutcome { Success, Failure, Denied }
```

### Field-by-field

| Field | Req? | Type | Meaning | Notes |
|---|:-:|---|---|---|
| `EventId` | yes | `Guid` | Idempotency key | Backs at-least-once transports: OtOpcUa's filtered-unique `EventId` index, ScadaBridge's first-write-wins. MxGateway has none today → **generate at write time**. |
| `OccurredAtUtc` | yes | `DateTimeOffset` | When it happened, UTC | MxGateway already uses `DateTimeOffset`. OtOpcUa / ScadaBridge store UTC-forced `DateTime` and widen at the mapping boundary. |
| `Actor` | yes | `string` | Who acted | SHOULD be the `ZB.MOM.WW.Auth` principal ([`SPEC.md`](SPEC.md) §4). Kept a `string` (no Auth dependency). Keyless events use a `"system"` / `"cli"` fallback rather than empty. |
| `Action` | yes | `string` | What was done (verb / event-type) | Carries each app's domain verb: OtOpcUa `EventType`, MxGateway `EventType`, ScadaBridge `{Channel}.{Kind}`. |
| `Outcome` | yes | `AuditOutcome` | Success / Failure / Denied | **New normalized field — no app stores it today; each derives it** (see below). |
| `Category` | no | `string?` | Coarse subsystem / grouping | OtOpcUa `Category` (`"Config"`); MxGateway constant `"ApiKey"`; ScadaBridge `Channel`. |
| `Target` | no | `string?` | The object acted on | ScadaBridge `Target` (direct). OtOpcUa / MxGateway have no dedicated field → null or fold into `DetailsJson`. |
| `SourceNode` | no | `string?` | Emitting logical node / host | OtOpcUa `SourceNode` (a logical node name, **not** an OPC UA NodeId); ScadaBridge `SourceNode`; MxGateway `RemoteAddress`. |
| `CorrelationId` | no | `Guid?` | Join to originating request / workflow | OtOpcUa / ScadaBridge direct; MxGateway has none today (left null). |
| `DetailsJson` | no | `string?` | Extension bag — all project-specific data | Must be valid JSON where stored (OtOpcUa enforces this with a CHECK constraint). Absorbs each app's surplus columns. |

## `AuditOutcome` — definition and app-state mapping

Three values, deliberately minimal — enough to normalize denials and failures without importing any
app's full taxonomy. `Outcome` is **derived** at each emit site (no app persists it today; OtOpcUa
encodes it implicitly in `EventType`, MxGateway in the event-type literal, ScadaBridge in `Status`):

| `AuditOutcome` | Meaning | OtOpcUa (`EventType`) | MxGateway (event type) | ScadaBridge (`AuditStatus` / `AuditKind`) |
|---|---|---|---|---|
| **`Success`** | The action completed | config-write verbs — `DraftCreated`, `DraftEdited`, `Published`, `RolledBack`, `NodeApplied`, `CredentialAdded`, `ClusterCreated`, `NodeAdded`, `ExternalIdReleased`, … | key-lifecycle — `init-db`, `create-key`, `list-keys`, `revoke-key`, `rotate-key` + all `dashboard-*` | `Status = Delivered` |
| **`Failure`** | The action was attempted and failed | *(none today — a failed actor flush is dropped, not recorded as an event)* | *(none emitted today)* | `Status ∈ { Failed, Parked, Discarded }` |
| **`Denied`** | The action was rejected by authorization / policy | `OpcUaAccessDenied`, `CrossClusterNamespaceAttempt` | `constraint-denied` | `Kind = InboundAuthFailure` |

Notes:

- **OtOpcUa has no `Failure` source.** Its vocabulary only distinguishes success-verbs from
  access-denials; an internal write failure is dropped (best-effort), not emitted as an event. So
  OtOpcUa produces only `Success` / `Denied` until/unless it adds failure events.
- **MxGateway emits only `Success` / `Denied`** today (no failure events; authentication
  success/failure is surfaced as gRPC status, not persisted — see its current-state doc).
- **ScadaBridge in-flight states** (`Submitted` / `Forwarded` / `Attempted`) are not terminal; when
  projecting to a single `Outcome` they collapse to the last-known terminal state. `Skipped` is not a
  user-facing outcome and is excluded from the canonical projection.

## Per-project mapping table (canonical ← native record)

Consolidated from the three current-state docs. "Direct" = field exists with the same role; the
right-hand notes flag the type bridges and synthesized fields.

| Canonical field | OtOpcUa `AuditEvent` (8 fields) | MxGateway `ApiKeyAuditRecord` (6 fields) | ScadaBridge `AuditEvent` (~25 fields) |
|---|---|---|---|
| `EventId` | `EventId` — direct (idempotency key) | **generate** new `Guid` (only `AuditId` rowid exists) | `EventId` — direct |
| `OccurredAtUtc` | `OccurredAtUtc` (`DateTime` UTC) → widen | `CreatedUtc` (store-assigned `DateTimeOffset`) — direct | `OccurredAtUtc` (`DateTime` UTC-forced) → widen |
| `Actor` | `Actor` — direct | `KeyId` (nullable → `"system"`/`"cli"` fallback) | `Actor` (nullable on system rows) |
| `Action` | `Action` (persisted as `"{Category}:{Action}"`) | `EventType` — direct | `{Channel}.{Kind}` (e.g. `ApiOutbound.ApiCall`) |
| `Outcome` | **derive** from `EventType` | **derive**: `constraint-denied`→`Denied`, else `Success` | **derive** from `Status` (+`InboundAuthFailure`→`Denied`) |
| `Category` | `Category` (`"Config"`) | constant `"ApiKey"` | `Channel` |
| `Target` | — none — (null or via `DetailsJson`) | — none — (`commandKind`/`target` embedded in `Details` text) | `Target` — direct |
| `SourceNode` | `SourceNode` (logical node, `NodeId.Value`) | `RemoteAddress` (dashboard path only) | `SourceNode` — direct |
| `CorrelationId` | `CorrelationId` (`CorrelationId.Value`) — direct | — none — | `CorrelationId` — direct |
| `DetailsJson` | `DetailsJson` — direct (also `ClusterId`/`GenerationId` on the SP path) | `Details` (plain string → store as-is or wrap) | the ~15 rich/plumbing fields (`ExecutionId`, `SourceSiteId`, `HttpStatus`, `DurationMs`, `ErrorMessage`, `RequestSummary`, `ResponseSummary`, `PayloadTruncated`, `Extra`, `ForwardState`, …) serialize here |

The canonical record is a **lossy projection**: it is sufficient for cross-project reporting, but each
project keeps its native record as the storage shape — ScadaBridge especially, whose partitioned SQL
schema, forwarding state, and reconciliation depend on the extra columns ([`SPEC.md`](SPEC.md) §5).
