7.6 KiB
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 (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
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 §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
Failuresource. 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 onlySuccess/Denieduntil/unless it adds failure events. - MxGateway emits only
Success/Deniedtoday (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 singleOutcomethey collapse to the last-known terminal state.Skippedis 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 §5).