Cairnloop.Outbound (cairnloop v0.5.1)

Copy Markdown View Source

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_trigger(conversation_ids, opts)

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 counttemplate_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(conversation_id, opts)

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.