# `Mailglass.Events`
[🔗](https://github.com/szTheory/mailglass/blob/v1.0.0/lib/mailglass/events.ex#L1)

Append-only event writer. The only legitimate path to
`mailglass_events`. Phase 6 `NoRawEventInsert` Credo check enforces
that no other code calls `Repo.insert(%Event{})` or
`Repo.insert_all("mailglass_events", ...)`.

## Public API

- `append/1` — standalone audit write. Opens its own `Repo.transact/1`.
  Returns `{:ok, %Event{}}`. Use for admin actions, `mix
  mail.doctor` breadcrumbs, tenant provisioning — anything without
  a companion domain mutation.
- `append_multi/3` — composable Ecto.Multi step. Use when writing
  an event alongside a Delivery insert or projection update.
  Returns an enriched `%Ecto.Multi{}`.

## Idempotency (PERSIST-03, MAIL-03 prevention)

When `attrs` carries an `:idempotency_key`, both paths use the
partial-unique-index conflict target:

    on_conflict: :nothing,
    conflict_target: {:unsafe_fragment, "(idempotency_key) WHERE idempotency_key IS NOT NULL"},
    returning: true

`append/1` detects the conflict-replay case and re-fetches the
existing row by `idempotency_key`. `append_multi/3` surfaces replay
to the caller via a follow-up `Multi.run` step inspecting the
returned event's `inserted_at`.

### The replay-detection sentinel: `inserted_at`, not `id`

The accrue precedent (bigserial PK) detects replay via `id == nil`
because Postgres auto-generates the id server-side; on conflict,
no row is inserted, RETURNING yields nothing, and Ecto populates
the struct with the client-side `nil` id. Mailglass uses UUIDv7
(autogenerated client-side by the `Mailglass.Schema` macro), so
the struct's `id` field is NEVER nil — Ecto has always filled it
before sending to the DB. The sentinel that IS nil on conflict is
any DB-defaulted column that only gets its value back via
RETURNING: `inserted_at` (populated by the migration's
`default: fragment("now()")`). Ecto issues #3132/#3910/#2694
describe the same underlying behavior; the detection mechanic
is just the column that tells the story.

## Auto-capture (D-05)

When `:tenant_id` is not in `attrs`, `Mailglass.Tenancy.current/0`
fills it. When `:trace_id` is not set, an OTel context probe fills
it (nil-tolerant; the probe is guarded so the module compiles even
when `:otel_propagator_text_map` is absent).

## Telemetry (D-04)

Both paths (well, `append/1` directly; `append_multi/3` via the
caller's `Repo.transact/1` call) emit
`[:mailglass, :events, :append, :start | :stop | :exception]`.
`:stop` metadata carries `inserted?: boolean` and
`idempotency_key_present?: boolean` so downstream replay counters
can hook without a second DB query.

# `attrs`

```elixir
@type attrs() :: %{
  optional(:type) =&gt; atom() | String.t(),
  optional(:delivery_id) =&gt; Ecto.UUID.t() | nil,
  optional(:occurred_at) =&gt; DateTime.t(),
  optional(:idempotency_key) =&gt; String.t() | nil,
  optional(:reject_reason) =&gt; atom() | nil,
  optional(:normalized_payload) =&gt; map(),
  optional(:metadata) =&gt; map(),
  optional(:tenant_id) =&gt; String.t(),
  optional(:trace_id) =&gt; String.t() | nil,
  optional(:needs_reconciliation) =&gt; boolean()
}
```

# `append`
*since 0.1.0* 

```elixir
@spec append(attrs()) :: {:ok, Mailglass.Events.Event.t()} | {:error, term()}
```

Standalone audit append — opens a transaction, inserts, translates
45A01 errors. Returns `{:ok, %Event{}}` on success (including replay).

# `append_multi`
*since 0.1.0* 

```elixir
@spec append_multi(Ecto.Multi.t(), atom(), attrs() | (map() -&gt; attrs())) ::
  Ecto.Multi.t()
```

Appends an insert step to an existing `Ecto.Multi`. The caller owns
the transact/commit lifecycle. Replay observability is the caller's
responsibility (add a `Multi.run` step inspecting
`event.inserted_at == nil` — see moduledoc "The replay-detection
sentinel" for why `inserted_at`, not `id`).

## Function-form attrs (I-03, Phase 3)

When `attrs` is a 1-arity function, it is called inside a `Multi.run`
step with the prior `changes` map. This allows callers to reference a
prior step's result (e.g. a just-inserted `Delivery`'s `:id`) in the
event's `:delivery_id` field. The step producing the attrs map is named
`:"<name>_attrs"` and the insert step is named `name`.

## Examples

    # Map form (existing):
    Events.append_multi(multi, :queued, %{type: :queued, delivery_id: id})

    # Function form (new in Phase 3):
    Events.append_multi(multi, :queued, fn %{delivery: d} ->
      %{type: :queued, delivery_id: d.id}
    end)

---

*Consult [api-reference.md](api-reference.md) for complete listing*
