> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Campaigns and Triggers

> After this ships, a tenant sets up "email everyone on their birthday" once, and it runs correctly every day for years, in each member's own timezone.

## 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`: `blast` or `automation`, 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_runs` for 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)*

1. A campaign is `blast` or `automation`. A blast runs once against a segment snapshot **taken at
   dispatch**, not at scheduling — the list is what it is when it goes out.
2. 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.
3. 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.
4. Leap-day birthdays fire on 28 February in non-leap years. Stating this because the alternative is
   a member who never gets one.
5. 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.
6. 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.
7. Every campaign declares a **category**, and marketing campaigns obey quiet hours and frequency
   caps with an optional per-campaign override (DEC-E5).
8. Every message a campaign produces goes through the cascade individually. A campaign is not a
   bypass; it is a reason to send.
9. `campaign_runs` records what ran, when, how many resolved, how many were excluded and why.

## Data *(normative)*

| Table                         | Key invariants                                                                                                                                                                |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `messaging.campaigns`         | `tenant_id`; `kind` blast\|automation; `category`; `template_id`; `segment_id` nullable; `status` draft\|scheduled\|running\|paused\|completed; quiet-hours and cap overrides |
| `messaging.campaign_triggers` | FK campaign; `type` event\|date\_property\|segment\_entered\|segment\_exited; configuration JSONB; `send_window_local`                                                        |
| `messaging.campaign_runs`     | FK campaign; `occurrence_key`; unique (`campaign_id`, `contact_id`, `occurrence_key`); counts of resolved and excluded; append-only                                           |

## API *(normative)*

| Endpoint                                     | Class      | Permission                                   | Budget                |
| -------------------------------------------- | ---------- | -------------------------------------------- | --------------------- |
| `GET/POST/PATCH /v1/messaging/campaigns`     | Management | `messaging.campaigns.{read\|create\|update}` | p95 \<1 s             |
| `POST /v1/messaging/campaigns/{id}/schedule` | Management | `messaging.campaigns.schedule`               | p95 \<1 s             |
| `POST /v1/messaging/campaigns/{id}/cancel`   | Management | `messaging.campaigns.cancel`                 | p95 \<1 s             |
| `GET /v1/messaging/campaigns/{id}/preview`   | Management | `messaging.campaigns.read`                   | p95 \<5 s, count only |

## Events *(normative)*

`messaging.campaign.triggered`, available as an outgoing webhook, carrying campaign and occurrence.

## Acceptance criteria *(normative)*

1. A birthday campaign sends once per member per year, in their timezone's send window, verified
   across a date line.
2. Re-running the job the same day produces zero additional messages.
3. A 29 February birthday fires on 28 February in a non-leap year.
4. A blast snapshots its segment at dispatch, not at scheduling.
5. A blast can be cancelled until dispatch starts, and not after.
6. A campaign with an invalid template is paused and its owner notified.
7. Each message goes through the cascade individually — a suppressed member in the segment receives
   nothing and the exclusion is counted.
8. A marketing campaign obeys quiet hours unless it declares an override.
9. **Negative:** no campaign path reaches an adapter without passing the cascade.

## Execution

Asynchronous pipeline. Trigger evaluation and fan-out in `backend/scheduler`, dispatch through the
marketing rail in `backend/workers`.

## Open questions

| # | Question                                                             | Decides | By              |
| - | -------------------------------------------------------------------- | ------- | --------------- |
| 1 | Default local send window for date-property campaigns — 09:00–11:00? | Daniel  | before approval |
| 2 | Maximum blast size before it requires a second confirmation?         | Daniel  | before approval |

## Changelog

| Version | Date       | Change        | Why | Author                 |
| ------- | ---------- | ------------- | --- | ---------------------- |
| 0.1.0   | 2026-08-17 | Initial draft | —   | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
