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.
Trigger conditions
- Pricing change cascading to a customer cohort — bid_award_notification chains into this.
- Holiday schedule or facility-closure notice that needs to reach a list.
- Recall or food-safety notice (rare but high-stakes — coordinate with food-safety log).
- Annual roll customer cohort notification (annual_price_roll fans into this).
- New product launch within a shared brand line.
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.
The 3 beats
-
01
Personalize per recipient
The runner loops the recipient list and stages one
proposed_actionsrow per customer with the personalized body. Variables (customer name, account ref, school year) substitute fromcontext.recipients. -
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.
-
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.
What's different after the workflow runs
- Each approved recipient has a row in
outbound_email_logstamped with the workflow_run_id. - Any unapproved recipient remains in
proposed_actionswith status=pending or rejected. - The workflow_runs row records the cohort filter, approved count, and send count.
- reflexion_log carries a
batch_notifytagged row for next-cohort comparison.
What can go wrong and how to recover
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>.
A second approve on the same recipient no-ops cleanly. Logged but harmless.
Precondition blocks the launch. Mike either tightens the filter or explicitly sets a higher max_recipients in the input (audited).
Already-sent recipients remain sent. Pending stage rows stay pending. Mike investigates, then resumes or cancels manually.
Adjacent workflows + diagrams
Code paths + invariants
| Concern | Where |
|---|---|
| Workflow contract | workflow_definitions WHERE workflow_type='batch_notify' |
| Idempotency key | workflow_id + customer_id |
| Circuit breaker | 5 consecutive failures → abort |
| Recipient cap | 50 default, override via max_recipients input |
| No-spam window | 48 hours since last batch_notify send |
| Kill switch | kill:high_risk_ops |
| Risk level | 4 |
| Expected duration | ~variable (cohort-size driven) |
| Trigger | hitl_approval · proposed_actions approved with action_type=bulk_customer_email |