Wiki · Workflow companion

Batch customer notification

Send the same message to a customer cohort — pricing change, holiday schedule, recall notice. Per-recipient personalization, HITL gates either per-recipient or cohort-wide, idempotency keys to prevent double-sends, and a circuit breaker that aborts the batch after 5 consecutive failures.

Stub · Contract present, runner stub-only
What this is

Batch customer notification

Batch notify is how Mike reaches a customer cohort without copy-pasting fifty emails. A filter defines the cohort (e.g. "all NYC DOE customers on bid B5875"), a template defines the body, and the runner personalizes per recipient, gates approval, and sends with idempotency.

It exists because hand-rolling bulk emails was the #1 source of "did we send that to the right customer?" anxiety. The v2 contract upgrade in R551 added the idempotency key + circuit breaker + per-recipient HITL option that closed the last gap.

Risk level 4 (high) — sending the wrong message to a cohort is an externally-visible mistake. The 50-recipient cap, kill switch, and per-recipient idempotency exist precisely for that reason.

When to use it

Trigger conditions

Heuristic

The 48-hour no-spam check catches the most common accidental double-send: re-running yesterday's batch by mistake. Recipients already emailed in the last 48 hours surface a warn, not a block — Mike confirms.

Step-by-step what happens

The 3 beats

  1. 01

    Personalize per recipient

    The runner loops the recipient list and stages one proposed_actions row per customer with the personalized body. Variables (customer name, account ref, school year) substitute from context.recipients.

    Writes proposed_actions
    Time ~80ms per recipient
    Kind loop_over_recipients
    Status stub
  2. 02

    Mike reviews per-recipient (or cohort-wide)

    On /proposed-actions.html, Mike can approve each row individually or use bulk-decide to approve the whole cohort. R560's atomic-claim prevents double-approve race.

    Writes proposed_actions.status
    Time Mike-paced
    Kind hitl_review
    Status stub
    Note cohort-wide bulk approve available
  3. 03

    Send with idempotency + circuit breaker

    Each approved recipient triggers an outbound email send. Idempotency key is workflow_id+customer_id — a duplicate trigger no-ops. After 5 consecutive failures the circuit breaker aborts the batch.

    Writes outbound_email_log
    Time ~1–3s per send
    Kind send_email
    Status stub
    Note circuit breaker · abort after 5 fails
Outcomes

What's different after the workflow runs

Recipient cap
≤ 50
precondition
Idempotency
Enforced
workflow+customer key
Circuit breaker
5 fails
auto-abort
HITL
Per-recipient
or cohort bulk
Failure modes

What can go wrong and how to recover

Circuit breaker trips (5 consecutive fails)

The batch aborts. Already-sent recipients are not affected; remaining staged rows stay in proposed_actions. Mike investigates the underlying email-send failure, then resumes with POST /admin/batch/resume?run_id=<id>.

Idempotency hit on retry

A second approve on the same recipient no-ops cleanly. Logged but harmless.

Recipient cap exceeded

Precondition blocks the launch. Mike either tightens the filter or explicitly sets a higher max_recipients in the input (audited).

Kill switch tripped mid-batch

Already-sent recipients remain sent. Pending stage rows stay pending. Mike investigates, then resumes or cancels manually.

Related

Adjacent workflows + diagrams

For developers

Code paths + invariants

ConcernWhere
Workflow contractworkflow_definitions WHERE workflow_type='batch_notify'
Idempotency keyworkflow_id + customer_id
Circuit breaker5 consecutive failures → abort
Recipient cap50 default, override via max_recipients input
No-spam window48 hours since last batch_notify send
Kill switchkill:high_risk_ops
Risk level4
Expected duration~variable (cohort-size driven)
Triggerhitl_approval · proposed_actions approved with action_type=bulk_customer_email
// Idempotency key (R551) const key = `${workflow_run_id}:${customer_id}`; if (sentKeys.has(key)) return { skipped: 'idempotent_noop' }; // Circuit breaker if (consecutiveFails >= 5) throw new Error('circuit_breaker_tripped');