Context
Two shapes of campaign, and they fail differently. A blast is one-shot to a segment: the risks are volume, list accuracy and the moment of no return. An automation is a standing trigger: the risks are duplicates, timezones and silently stopping. Treating them as one thing produces a design that handles neither well. The date-property trigger is the one worth designing carefully. “Happy birthday” is the most common automation in the category and the easiest to get embarrassingly wrong — sent a day early in one timezone, twice on a leap year, or to everyone at 03:00 because the cron runs in UTC.Scope (normative)
messaging.campaigns:blastorautomation, with a category.messaging.campaign_triggers: event, date property, segment entered or exited.- Timezone-aware date-property scheduling in the recipient’s local send window.
messaging.campaign_runsfor per-run accounting and idempotency.- Blast scheduling, preview of the recipient count, and cancellation before dispatch.
- Per-campaign quiet hours and frequency cap overrides.
Non-scope (normative)
- Multi-step journeys with branching. Deliberately out: a journey builder is its own product and a poor one is worse than none.
- A/B testing — F2.
- The rules engine, which is
loyalty. A campaign consumes events; it does not evaluate conditions over ledgers. - Segment definition, which is
core.
Behaviour (normative)
- A campaign is
blastorautomation. A blast runs once against a segment snapshot taken at dispatch, not at scheduling — the list is what it is when it goes out. - Date-property triggers run per tenant on cron fan-out, at a send window in the recipient’s own timezone, falling back to the tenant’s. A birthday email at 03:00 local is worse than none.
- Duplicates are impossible by construction: a unique key of (campaign, contact, occurrence date). Re-running the job, a retry, or a second worker cannot produce a second message.
- Leap-day birthdays fire on 28 February in non-leap years. Stating this because the alternative is a member who never gets one.
- A campaign whose template, segment or trigger becomes invalid is paused and its owner notified, never silently skipped. A campaign that stopped without telling anyone is discovered months later.
- Blasts are cancellable until dispatch starts and report a recipient count beforehand. Once dispatch starts they run to completion — a half-sent blast is not something we can un-send.
- Every campaign declares a category, and marketing campaigns obey quiet hours and frequency caps with an optional per-campaign override (DEC-E5).
- Every message a campaign produces goes through the cascade individually. A campaign is not a bypass; it is a reason to send.
campaign_runsrecords what ran, when, how many resolved, how many were excluded and why.
Data (normative)
API (normative)
Events (normative)
messaging.campaign.triggered, available as an outgoing webhook, carrying campaign and occurrence.
Acceptance criteria (normative)
- A birthday campaign sends once per member per year, in their timezone’s send window, verified across a date line.
- Re-running the job the same day produces zero additional messages.
- A 29 February birthday fires on 28 February in a non-leap year.
- A blast snapshots its segment at dispatch, not at scheduling.
- A blast can be cancelled until dispatch starts, and not after.
- A campaign with an invalid template is paused and its owner notified.
- Each message goes through the cascade individually — a suppressed member in the segment receives nothing and the exclusion is counted.
- A marketing campaign obeys quiet hours unless it declares an override.
- Negative: no campaign path reaches an adapter without passing the cascade.
Execution
Asynchronous pipeline. Trigger evaluation and fan-out inbackend/scheduler, dispatch through the
marketing rail in backend/workers.