# LogNotificationEvent

## Permission Scope

ingest

## Overview

logNotificationEvent appends a source event into `NotificationEvent`. It is the integration point source modules call right after committing a domain change, in the same transaction, so that emission and the business change either both land or both roll back. The command accepts the event envelope, normalizes and hashes the payload, checks the idempotency boundary, and either inserts a new `PENDING` row or returns a duplicate no-op.

The command performs no delivery work and resolves no recipients — it only durably records the event. Actual fan-out happens asynchronously: the insert of a `PENDING` row is observed by the `dispatch-notification-events` executor (CDC `recordCreatedTrigger`), which invokes `dispatchNotification`. This keeps the source module's commit path free of channel-adapter latency and lets `logNotificationEvent` stay domain-agnostic — it never reads the source module's tables.

The command is deliberately a thin, append-only writer so it can be the stable seam between every emitter and the notification module: the input envelope and the `NotificationEventPayload` shape are the cross-module contract, and a host app wires its source modules to this single command (e.g. via an `emitNotificationEvent` helper that resolves DESTINATION routing context such as a shared channel before logging).

## Business Rules

- `eventType`, `sourceType`, `sourceId`, and `payload` are required.
- The caller supplies the actor and any naturally-interested recipient hints it already knows in `payload.recipients`; the command stores their content unchanged and does not expand them.
- The payload is **canonicalized** before storage and hashing: object keys are recursively sorted so the serialized form (and therefore the `payloadHash`) is independent of key insertion order and whitespace. The stored `payload` is the canonical JSON, not the caller's byte sequence.
- `eventType` is stored as an opaque string and is **not** validated against a hard-coded enum at log time — the dispatcher resolves it against the Event Catalog and rejects unknown types with `CATEGORY_NOT_FOUND` at dispatch. This keeps the module domain-independent (no module-owned event vocabulary).
- The command is idempotent on `(eventType, sourceType, sourceId, payloadHash)`; `payloadHash` is computed from the canonicalized payload.
- A duplicate emission returns `{ duplicate: true }` with the existing row and does not insert a second row or enqueue dispatch work.
- A **concurrent emit** that wins the unique-index race between the idempotency probe and the insert is folded into the duplicate path: the command re-selects the tuple and returns `{ duplicate: true }` with the winner's row; an insert failure with no matching row rethrows so the emitter's transaction is not corrupted.
- The command never reads source-module tables directly.

## Process Flow

```mermaid
flowchart TD
    A[Receive logNotificationEvent] --> B[Validate envelope, normalize payload, compute payloadHash]
    B --> C{Existing NotificationEvent with same idempotency key?}
    C -->|Yes| D[Return existing row, duplicate true]
    C -->|No| E[Insert NotificationEvent with status PENDING]
    E -->|Insert succeeds| F[Return created row, duplicate false]
    E -->|Unique-index violation, concurrent emit| RC{Re-select finds the tuple?}
    RC -->|Yes| D
    RC -->|No| RT[Rethrow the insert failure]
    F -.CDC recordCreatedTrigger.-> G[dispatch-notification-events executor]
```

## External Dependencies

- source module contract - caller provides the event envelope, actor, and recipient hints; no notification-side schema is read

## Error Scenarios

- **MISSING_REQUIRED_FIELD**: One or more required input fields are missing or blank
- **MISSING_ENTITY_REFERENCE**: `sourceType` or `sourceId` is missing
- **INVALID_PAYLOAD**: payload cannot be normalized to JSON

(A non-blank but catalog-unknown `eventType` is accepted here and rejected later at dispatch with `CATEGORY_NOT_FOUND`.)

## Test Cases

- creates a PENDING NotificationEvent for a new idempotency key and returns `duplicate: false`
- returns `duplicate: true` with the existing row when the same idempotency key is logged twice
- accepts a non-blank `eventType` even when it is not yet in the Event Catalog (validation is deferred to dispatch)
- rejects a blank `eventType`
- rejects a missing source reference (`sourceType` or `sourceId`)
- rejects an unparseable payload
- preserves the caller-supplied payload content (canonical key order) without reading source tables
- falls back to duplicate: true when a concurrent emit wins the unique-index race between the probe and the insert
- rethrows an insert failure when the re-select finds no row for the tuple (not a duplicate race)
- a differing payload for the same `(eventType, sourceType, sourceId)` produces a distinct row (different `payloadHash`)
