Packages

Host-owned customer support automation for Phoenix apps — governed drafting, retrieval-backed answers, and durable workflow tools.

Current section

Files

Jump to
cairnloop lib cairnloop outbound bulk_envelope.ex
Raw

lib/cairnloop/outbound/bulk_envelope.ex

defmodule Cairnloop.Outbound.BulkEnvelope do
@moduledoc """
Durable audit envelope for a bulk outbound action (D-13, OBS-02-shaped). Snapshots
template_id, rendered_body, and recipient cohort at confirmation time. Per-recipient
delivery flows through `Outbound.trigger/2` (sealed) carrying the envelope id. Refused
attempts persist with `status: :refused_cap_exceeded`.
## Columns
- `:id` — UUID generated by the caller at `bulk_trigger/2` confirmation time
(`autogenerate: false` — callers must supply an explicit `Ecto.UUID.generate/0`
value so tests and downstream correlation keys can assert on it).
- `:template_id` — string id of the configured template that was used at confirmation.
- `:rendered_body` — the fully rendered message body at confirmation time. **Never**
re-rendered at worker run time (CLAUDE.md: snapshot trust facts at decision time).
- `:recipient_conversation_ids` — list of `:integer` conversation ids the operator
targeted. Persisted for audit (OBS-02); per-recipient delivery is tracked on the
individual `system_outbound` `Message` rows created via the sealed
`Outbound.trigger/2` lane.
- `:count` — denormalized cohort size (must be `> 0`).
- `:effective_cap` — the value of `max_batch_size/0` at decision time. Snapshotted
on BOTH the `:submitted` and `:refused_cap_exceeded` paths so OBS-02 readers can
compare `count` against the policy that was actually in effect when the operator
confirmed (WR-05). Must be `> 0`.
- `:requested_by` — actor string (operator, system, etc.); nullable so system-initiated
bulk actions are representable.
- `:requested_at` — confirmation timestamp.
- `:status``:submitted` (default) or `:refused_cap_exceeded`. A refused row records
that an oversized cohort was attempted — mirrors `Governance.propose_blocked`'s
"blocked attempts persist" posture so OBS-02 reads see both lanes from one table.
- `:refused_reason` — operator-facing reason string (only set when refusing).
## Status enum
:submitted — fan-out enqueued (per-recipient delivery is tracked on Message rows)
:refused_cap_exceeded — cohort exceeded max_batch_size; no fan-out occurred
## Validation
- `:id`, `:template_id`, `:rendered_body`, `:recipient_conversation_ids`, `:count`,
`:effective_cap`, and `:requested_at` are required.
- `:count` must be `> 0` (a zero-cohort envelope has no audit value).
- `:effective_cap` must be `> 0`.
See `priv/repo/migrations/20260527063000_add_outbound_bulk_envelopes.exs` for the
underlying table definition (D-15).
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: false}
schema "cairnloop_outbound_bulk_envelopes" do
field(:template_id, :string)
# Snapshotted at confirmation time — never re-read at worker run time (CLAUDE.md).
field(:rendered_body, :string)
field(:recipient_conversation_ids, {:array, :integer})
field(:count, :integer)
# WR-05: snapshot of `max_batch_size/0` at decision time. Lets OBS-02
# readers compare `count` against the policy that was actually in effect
# when the operator confirmed.
field(:effective_cap, :integer)
field(:requested_by, :string)
field(:requested_at, :utc_datetime_usec)
# :submitted (fan-out enqueued) | :refused_cap_exceeded (cap-exceeded attempt persisted).
field(:status, Ecto.Enum, values: [:submitted, :refused_cap_exceeded], default: :submitted)
field(:refused_reason, :string)
timestamps()
end
@castable [
:id,
:template_id,
:rendered_body,
:recipient_conversation_ids,
:count,
:effective_cap,
:requested_by,
:requested_at,
:status,
:refused_reason
]
@required [
:id,
:template_id,
:rendered_body,
:recipient_conversation_ids,
:count,
:effective_cap,
:requested_at
]
@doc """
Builds a changeset for the bulk envelope.
Casts all writable fields and validates required ones (`:id`, `:template_id`,
`:rendered_body`, `:recipient_conversation_ids`, `:count`, `:effective_cap`,
`:requested_at`). Enforces `count > 0` and `effective_cap > 0` (zero values
have no audit value).
`:requested_by` is intentionally optional — system-initiated bulk actions are
representable with `nil` here. `:status` defaults to `:submitted`; setting it to
`:refused_cap_exceeded` with a `:refused_reason` is also valid.
"""
def changeset(envelope, attrs) do
envelope
|> cast(attrs, @castable)
|> validate_required(@required)
|> validate_number(:count, greater_than: 0)
|> validate_number(:effective_cap, greater_than: 0)
end
end