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.
Summary
Functions
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).
Triggers an outbound message for a given conversation.
Functions
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 asrequested_byon 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.
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 intoMessage.metadata["bulk_envelope_id"]and into theOutboundWorkerjob args so the per-recipient delivery participates in the bulk audit envelope and the Obanunique: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.