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');
Changelog

Dated trail · spot stale claims

Dated trail of when this doc was last touched, what changed, and what to look at if it feels stale.

DateRoundChangeTouched by
2026-05-26R586Added CHANGELOG · SCHEMA · RUNBOOK · BACKLOG sections — wiki became best-in-class operating documentation.Mike + Claude
2026-05-25R584/R585Wiki originally shipped — 8-section structure (hero / what / when / steps / outcomes / failure-modes / related / for-developers).Mike + Claude
If today is more than 60 days past the latest changelog row, treat live system behavior as the source of truth. The doc may have drifted — verify against the workflow contract in workflow_definitions WHERE workflow_type='batch_notify' before acting on these claims.
Schema · data contract

The machine-readable spec

Canonical fields, table names, endpoint signatures. What code should match, what tests should assert. workflow_type · batch_notify · risk_level · 2.

Inputs (required + optional)

FieldTypeDescription
batch_idstringUnique batch identifier. Required.
recipientsjsonArray of {customer_id, channel, template}.
template_idstringNotification template. Required.
send_afterdatetime?Optional delayed-send.

D1 tables written

TableOperationTrigger
outbound_email_logINSERT (one per recipient)All status=pending_review
batch_notify_runsINSERTRun state
eventsINSERT (batch_notify.staged)audit

Endpoints called

MethodPathPurpose
POST/api/batch-notify/stageStage a batch
POST/api/batch-notify/:id/sendMike approves whole batch
GET/api/batch-notify/:idBatch status

Events fired

event_typeWhenSubscribers
batch_notify.stagedOn stageaudit + queue UI
batch_notify.sentPer-recipient senddelivery tracking
batch_notify.bounceBounce receivedcustomer_health
Runbook · when it breaks

It broke at 2am — what now

Different from "how do I use this." This is the page Mike pulls up when something is wrong: logs to check, recovery steps, who to escalate to.

Scenario · Batch staged 500 emails but Mike only approved 1 — what happened to the rest?

Batch approval is all-or-nothing today. If you click approve on one row in /proposed-actions, you only approve one.

  1. Use batch-send: POST /api/batch-notify/<id>/send approves the entire batch.
  2. Check status: SELECT status, COUNT(*) FROM outbound_email_log WHERE batch_id='<id>' GROUP BY status
  3. Discard rest: If only one was wanted, bulk-reject the others.

Scenario · Bounce rate spiked after a batch send

Stale email addresses or content flagged as spam.

  1. Inspect bounces: SELECT customer_id, bounce_reason FROM outbound_email_log WHERE batch_id='<id>' AND status='bounced'
  2. Update emails: Patch customer.email_primary where bounced.
  3. Throttle: If > 5% bounce rate, pause future batches until cleanup.

Scenario · Batch_notify cron didn't pick up scheduled batch

send_after window passed but emails still queued.

  1. Check cron: npx wrangler tail during cron tick
  2. Manual send: POST /api/batch-notify/<id>/send
  3. Verify schedule: Cron expression in wrangler.toml — should be */5 or similar.

Logs to check

Kill switch · emergency stop

If this workflow is misbehaving in a high-impact way (creating bad proposed_actions in volume, pushing wrong things to NS), flip a kill switch:

See kill-switches-state-machine.html for the full state machine + recovery procedure.

Escalation

Primary: Mike Levine (single-admin) · mikelevine@globalfoodsolutions.co. For prolonged outage during business hours, notify warehouse lead + accounting lead so they can defer dependent work.

Backlog · open questions

What's not done · what's uncertain

What's not done, what's uncertain, what we punted. Captured so it survives context switches and doesn't die in someone's head.