Chimeway.Workflows.ProgressionOutcome (chimeway v1.0.0)

Copy Markdown View Source

Pure mapper from canonical delivery facts to a curated workflow-facing outcome vocabulary used by Phase 25 progression rules.

The mapper has no IO and no Repo access. Callers preload the prior Chimeway.Delivery row (and optionally the latest Chimeway.DeliveryAttempt) inside the same progression transaction and pass them in. This keeps branch semantics deterministic, replay-safe, and explainable from durable rows alone per D-12.

Curated vocabulary (D-04)

from_delivery/2 returns either :not_branchable_yet or a three-tuple {:branchable, outcome, evidence} where outcome is one of:

  • :delivereddelivery.status == :succeeded
  • :suppresseddelivery.status == :suppressed
  • :temporary_failuredelivery.status == :failed
  • :retries_exhausteddelivery.status == :cancelled and
                          `suppression_reason == "retries_exhausted"`
  • :permanent_failuredelivery.status == :cancelled and
                          `suppression_reason == "permanent_failure"`
  • :bounceddelivery.status == :cancelled and
                          `suppression_reason == "bounced"`

Early-fire warning for temporary_failure (WR-02)

temporary_failure resolves from a delivery.status == :failed row, which is NOT terminal: Chimeway.Deliveries's @allowed_transitions permits failed: [:dispatched], the path Oban uses while a delivery is being retried. A workflow authoring:

%{"kind" => "on_outcome", "outcome" => "temporary_failure", "to_step" => "email"}

fires to_step on the FIRST transient :failed write — before any retry has been attempted. The original delivery may still succeed on a later attempt, leaving the host with BOTH a successful primary delivery AND the destination-step delivery for the same notification.

If the intent is "fire after retries are exhausted", use retries_exhausted (which resolves only from a guaranteed-terminal :cancelled row with suppression_reason == "retries_exhausted"). If the intent IS "fire immediately on the first failure so we can try a different channel while the original retries", pair the destination step's notifier with an idempotency key so the host can collapse a primary success + an early-fire escalation to one user-visible delivery.

See Chimeway.Notifier moduledoc near @progress_outcomes for the authoring-time version of this warning.

Per D-05, :pending, :dispatched, and :digested deliveries always return :not_branchable_yet. Cancelled rows whose suppression_reason is not in the curated set also return :not_branchable_yet so workflow rules never advance on a meaning the contract did not explicitly assign.

Evidence

The third tuple element carries replay-safe primitive evidence so workflow transitions can persist a sufficient explanation of why the outcome was chosen without any callback re-entry:

  • :delivery_status — string copy of delivery.status
  • :suppression_reason — string copy of delivery.suppression_reason
                          (may be `nil`)
  • :attempt_outcome — string copy of latest attempt outcome (or nil)
  • :attempt_error_class — string copy of latest attempt error class
                          (or `nil`)

Summary

Types

evidence()

@type evidence() :: %{
  delivery_status: String.t(),
  suppression_reason: String.t() | nil,
  attempt_outcome: String.t() | nil,
  attempt_error_class: String.t() | nil
}

outcome()

@type outcome() ::
  :delivered
  | :suppressed
  | :temporary_failure
  | :retries_exhausted
  | :permanent_failure
  | :bounced

result()

@type result() :: {:branchable, outcome(), evidence()} | :not_branchable_yet

Functions

from_delivery(delivery, attempt)

@spec from_delivery(Chimeway.Delivery.t(), Chimeway.DeliveryAttempt.t() | nil) ::
  result()