# `Cairnloop.Outbound`
[🔗](https://github.com/szTheory/cairnloop/blob/main/lib/cairnloop/outbound.ex#L1)

Facade for programmatically triggering support lifecycle events (outbound messages).

## Sealing posture (D-12)

`trigger/2`'s **public signature** `trigger(conversation_id, opts)` is sealed at the
Phase 22 / 23 / 24 contract. Phase 25 adds the additive optional key
`:bulk_envelope_id` (default `nil`); Phase 24 callers that do not pass it continue
to observe identical behavior.

## Bulk fan-out (Phase 25, D-13)

`bulk_trigger/2` is the new envelope entry point that lets `InboxLive` (plan 03)
fan out the same per-recipient `trigger/2` primitive across N conversations under
a single durable `Cairnloop.Outbound.BulkEnvelope` row. Both `trigger/2` and
`bulk_trigger/2` share a private `build_trigger_multi/2` helper that returns an
`Ecto.Multi` WITHOUT running it — research Open Question 1 — so the bulk path
composes per-recipient multis into one merged transaction without nesting.

## Cap (D-09)

`bulk_trigger/2` enforces `length(conversation_ids) <= max_batch_size()` at the
envelope boundary regardless of caller (defense-in-depth — research Pitfall 4).
`max_batch_size/0` reads the `:cairnloop, :max_batch_size` application env (default
`25`) — research Open Question 3.

# `bulk_trigger`

Bulk-triggers outbound messages across N conversations under a single durable
`Cairnloop.Outbound.BulkEnvelope` row (D-13). Per-recipient delivery flows through
the sealed `Outbound.trigger/2` lane via the shared private `build_trigger_multi/2`
helper (research Open Question 1).

**The caller is responsible for rendering** the template body and passing it as
`:rendered_body`. `bulk_trigger/2` NEVER re-resolves the template — the envelope
persists exactly the string the caller passed (CLAUDE.md "snapshot trust facts at
decision time"; T-25-03 mitigation).

## Options

  * `:template_id` (required) - The template id that was rendered into `:rendered_body`.
    Persisted on the envelope for audit; NEVER used to re-render.
  * `:rendered_body` (required) - The pre-rendered message body, snapshotted on the
    envelope row at confirmation time.
  * `:actor` (optional) - Actor string (operator, system, etc.); recorded as
    `requested_by` on the envelope.
  * `:auditor` (optional) - Custom auditor implementation. Defaults to the configured
    `:cairnloop, :auditor`.

## Cap (D-09, defense-in-depth — research Pitfall 4)

If `length(conversation_ids) > max_batch_size()`, a `BulkEnvelope` row is still
inserted with `status: :refused_cap_exceeded` and a humanized `:refused_reason`
(research Open Question 5 — mirrors `Governance.propose_blocked` posture so OBS-02
reads see both lanes from one table). The function then returns
`{:error, :batch_too_large}` and emits a telemetry event with
`outcome: :refused_cap_exceeded`.

## Telemetry (D-B enum-only labels)

`[:cairnloop, :outbound, :bulk, :triggered]` is emitted with metadata containing
ONLY `outcome :: :submitted | :refused_cap_exceeded` and `count` — `template_id`,
recipient identifiers, and actor are NEVER in telemetry labels (research Pitfall 5).
Those live in the durable envelope row and the auditor metadata.

# `trigger`

Triggers an outbound message for a given conversation.

## Options
  * `:template_id` (required) - The identifier for the template to use.
  * `:content` (optional) - The content of the message. Defaults to a template reference.
  * `:schedule_in` (optional) - Delay in seconds before sending the message.
  * `:actor` (optional) - The entity triggering the outbound action for auditing.
  * `:auditor` (optional) - Custom auditor implementation.
  * `:bulk_envelope_id` (optional, Phase 25 additive — D-12) - When set, this
    correlation key is threaded into `Message.metadata["bulk_envelope_id"]` and
    into the `OutboundWorker` job args so the per-recipient delivery participates
    in the bulk audit envelope and the Oban `unique:` dedup tuple
    `(conversation_id, template_id, bulk_envelope_id)` (D-11).

## Telemetry (D-B enum-only labels — WR-04)

`[:cairnloop, :outbound, :triggered, :start | :stop | :exception]` is emitted
with metadata containing ONLY `outcome :: :triggered` (enum-only). The
per-recipient `conversation_id`, `template_id`, `actor`, and `schedule_in`
facts live in the durable `Message` row, the `OutboundWorker` job args, and
the auditor metadata — NEVER in telemetry labels. This protects attached
Prometheus / StatsD / Datadog handlers from cardinality explosion and PII
leakage; Phase 25 bulk fan-out routes through this lane per-recipient, so a
25-recipient bulk emits 25 enum-only events. A host that genuinely needs
per-recipient audit by actor should attach to the auditor's
`:outbound_trigger` event instead.

---

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