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 ownRepo.transact/1. Returns{:ok, %Event{}}. Use for admin actions,mix mail.doctorbreadcrumbs, 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: trueappend/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.
Summary
Functions
Standalone audit append — opens a transaction, inserts, translates
45A01 errors. Returns {:ok, %Event{}} on success (including replay).
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).
Types
@type attrs() :: %{ optional(:type) => atom() | String.t(), optional(:delivery_id) => Ecto.UUID.t() | nil, optional(:occurred_at) => DateTime.t(), optional(:idempotency_key) => String.t() | nil, optional(:reject_reason) => atom() | nil, optional(:normalized_payload) => map(), optional(:metadata) => map(), optional(:tenant_id) => String.t(), optional(:trace_id) => String.t() | nil, optional(:needs_reconciliation) => boolean() }
Functions
@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).
@spec append_multi(Ecto.Multi.t(), atom(), attrs() | (map() -> 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)