# `Chimeway.Workflows.ProgressionOutcome`
[🔗](https://github.com/jonlunsford/chimeway/blob/v1.0.0/lib/chimeway/workflows/progression_outcome.ex#L1)

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:

  * `:delivered`           — `delivery.status == :succeeded`
  * `:suppressed`          — `delivery.status == :suppressed`
  * `:temporary_failure`   — `delivery.status == :failed`
  * `:retries_exhausted`   — `delivery.status == :cancelled` and
                              `suppression_reason == "retries_exhausted"`
  * `:permanent_failure`   — `delivery.status == :cancelled` and
                              `suppression_reason == "permanent_failure"`
  * `:bounced`             — `delivery.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`)

# `evidence`

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

# `outcome`

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

# `result`

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

# `from_delivery`

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

---

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