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

# Campaign Safety Preflight

> The campaign_safety_audits system — a deterministic, auditable preflight that can gate campaign launch behind a hard-block severity ladder.

Before v0.18.16, campaign sanity checks were ad-hoc — CSV exports and one-off scripts run by hand before a risky send. **Campaign safety preflight** (BORD-699/700/701) replaces that with a durable, auditable database record of what was checked, when, and against which exact recipient set — and an **opt-in** launch guard that can refuse to launch a campaign whose preflight didn't pass.

<Info>
  The preflight is off by default. Existing campaigns launch exactly as before unless a campaign's metadata explicitly opts in via `launchSafety.requireSafetyPreflight`.
</Info>

## What a preflight run does

`runCampaignSafetyPreflightInSupabase` runs a single deterministic pass over a campaign batch's **queued** recipients:

1. Resolves the campaigns in scope — either every campaign sharing a `batchKey` (a campaign "family," matched via `campaigns.metadata->>'batchKey'`), or an explicit list of campaign ids.
2. Loads the queued `campaign_recipients` for those campaigns, joined to the safety-relevant contact columns (`consent_state`, `subscriber_status`, `deleted_at`, `deletion_requested_at`, `metadata`).
3. Opens an audit row (`campaign_safety_audits`, status `running`).
4. Runs every registered deterministic gate over the recipient set and collects findings.
5. Persists the findings (`campaign_safety_audit_findings`) and completes the audit — writing a `recipient_set_hash` over the sorted recipient ids and the rolled-up severity counts, then flips status to `completed`.
6. On any error after the audit row opens, the audit is marked `failed` with the error reason and the error is re-thrown — a preflight never silently reports success on a crash.

### Shipped gate: DNC

The only gate live today is **`dncGate`** — a Switchbord-data-only, self-contained check. It flags a recipient as do-not-contact (severity `dnc`, finding type `dnc_opted_out`) when any of these are true, and records which ones matched as evidence:

* `consent_state === "opted_out"`
* `subscriber_status === "unsubscribed"`
* the contact has `deleted_at` set
* the contact has `deletion_requested_at` set

<Note>
  The adapter has a documented **extension point** for identity-expanded gates (Travio/Neo4j/UDB prior-outreach, travel-status hits, pax fuzzy matching — tracked as BORD-702/703/704/705). Those gates need richer identity-resolved context than the recipient set alone provides and are intentionally out of scope for this initial preflight.
</Note>

## Severity ladder

Findings carry one of seven severities, from most to least serious:

| Severity         | Blocks launch?                                                                                                | Meaning                                                                                                                                  |
| ---------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `hard_block`     | **Yes** — any hard-block finding fails the launch guard                                                       | Reserved for the identity-expanded gates (not yet implemented) that would represent a definite send to someone who must not be contacted |
| `medium_review`  | No — surfaced for operator review                                                                             | Reserved for future gates                                                                                                                |
| `dnc`            | No — surfaced for operator review, but every offending recipient is one this preflight expects to be excluded | The shipped DNC gate's severity                                                                                                          |
| `prior_outreach` | No                                                                                                            | Reserved for future gates                                                                                                                |
| `status_hit`     | No                                                                                                            | Reserved for future gates                                                                                                                |
| `pax_fuzzy`      | No                                                                                                            | Reserved for future gates                                                                                                                |
| `info`           | No                                                                                                            | Free-form informational findings; not counted in any rollup column                                                                       |

<Warning>
  Only `hard_block` findings gate launch today. The shipped DNC gate deliberately uses `dnc`, not `hard_block` — a completed audit with only DNC findings still passes the launch guard. Treat DNC findings as an operator review signal, not a launch blocker, until BORD-706 (apply-withholds mode) ships.
</Warning>

## The launch guard

A campaign opts in to the guard via its own metadata (`launchSafety.requireSafetyPreflight: true`) or by the caller explicitly passing `requireSafetyPreflight: true` to the launch call. When enabled, `launchCampaignInSupabase` refuses to launch unless **all** of the following hold:

1. A **completed** audit exists for this campaign's `batchKey` (or campaign id, if no batch key).
2. That audit's `hard_block_count` is exactly zero.
3. The audit's `recipient_set_hash` matches a **live** hash recomputed over the campaign's current queued recipients at launch time.

If any check fails, `launchCampaign` throws with a specific reason (`no completed campaign safety preflight audit found`, `... has N hard-block finding(s)`, or `recipient set changed since audit ...; rerun preflight`) and marks the launch attempt failed — it never launches partially.

```text theme={null}
launchCampaign (requireSafetyPreflight = true)
  1. resolve batchKey or campaignId
  2. load LIVE queued campaign_recipients ids for that population
  3. fetch latest COMPLETED audit for the same population
  4. evaluateCampaignLaunchGuard(audit, liveQueuedIds):
       - no audit                         → BLOCK
       - audit.hardBlockCount > 0         → BLOCK
       - live set empty OR audit count 0  → BLOCK (a preflight of nothing proves nothing)
       - sha256(sorted live ids) ≠ audit.recipientSetHash → BLOCK (stale — rerun)
       - otherwise                        → PASS, launch proceeds
```

### Why the hash check exists

The `recipient_set_hash` is a `sha256:`-prefixed digest of the sorted queued `campaign_recipient` ids the audit actually covered. Recomputing and comparing it at launch time closes an obvious gap: if recipients are added or changed *after* the preflight ran (a bigger audience, a re-materialized batch, a manual recipient edit), the audit is stale evidence and must not be trusted. The guard forces a fresh preflight run rather than launching against an audience nobody actually checked.

<Info>
  For a `batchKey` audit, the hash is computed over the **union of all queued recipients across the whole batch family** — matching the population the preflight itself hashed. A campaign-id audit hashes only that campaign's own queued recipients. The guard resolves the same population the audit used before comparing, so the two hashes are always apples-to-apples.
</Info>

### Fail-closed by design

The launch guard was hardened against a few specific bypass paths during adversarial review:

* The `requireSafetyPreflight` enable flag is read from **raw campaign metadata JSON**, not through the parsed/validated schema — so a malformed, unrelated metadata field elsewhere on the campaign can never silently disable the gate.
* A `batchKey` audit's live hash is recomputed over the *entire batch family*, not a single campaign, so it can never trivially match a narrower population.
* An **empty** audit or an empty live recipient set is refused outright rather than "vacuously" passing — a preflight of nothing is not evidence anything was checked.

## Data model

Two workspace-scoped, RLS-protected tables (via `app.has_workspace_access`):

**`campaign_safety_audits`** — one row per preflight run.

| Column                                                                                                                | Notes                                                                                                                 |
| --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `batch_key` / `campaign_ids`                                                                                          | At least one populated — either a batch family or an explicit campaign id set                                         |
| `mode`                                                                                                                | `audit_only` (default — produces findings, touches nothing) or `apply_withholds` (BORD-706, not yet implemented)      |
| `status`                                                                                                              | `running` → `completed` or `failed`; a lifecycle state, not a pass/fail verdict                                       |
| `recipient_set_hash` / `recipient_count`                                                                              | Set on completion; drives the launch guard's staleness check                                                          |
| `hard_block_count`, `medium_review_count`, `dnc_count`, `prior_outreach_count`, `status_hit_count`, `pax_fuzzy_count` | Rolled up from findings on completion (recomputed from the findings table, not incrementally tracked, to avoid drift) |
| `config` / `summary` / `artifact_paths`                                                                               | Free-form run config and operator-report payload; no PII inline                                                       |

**`campaign_safety_audit_findings`** — N rows per audit, one per (recipient, finding type) hit.

| Column                                 | Notes                                                                                                      |
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `severity`                             | One of the seven values in the ladder above                                                                |
| `finding_type`                         | Free text (e.g. `dnc_opted_out`) so future gates can add types without a migration                         |
| `campaign_recipient_id` / `contact_id` | Which recipient/contact the finding is about                                                               |
| `matched_travio_id` / `match_methods`  | Identity-expansion evidence for future gates                                                               |
| `evidence`                             | Structured evidence an operator needs to action the finding (e.g. which consent/subscriber fields matched) |
| `confidence`                           | `high` / `medium` / `low`, for fuzzy gates                                                                 |

## Running a preflight

There is no dedicated UI surface yet — `runCampaignSafetyPreflightInSupabase(admin, { workspaceId, batchKey | campaignIds, mode?, config? })` is called directly from operational tooling ahead of a guarded launch. Pass either a `batchKey` (preferred for a multi-campaign family) or an explicit `campaignIds` array; the call throws if neither is usable, so an audit can never be started against an unbounded or ambiguous population.

<Tip>
  Run the preflight again any time recipients change after a prior run — the launch guard will reject a stale audit anyway, but re-running proactively avoids a failed launch attempt at go-live time.
</Tip>

## Triage

| Symptom                                                                | Likely cause                                                                                  | What to do                                                                                                               |
| ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Launch throws `no completed campaign safety preflight audit found`     | `requireSafetyPreflight` is on but no audit has ever run for this campaign/batch              | Run `runCampaignSafetyPreflightInSupabase` for the batch/campaign, then retry launch                                     |
| Launch throws `... has N hard-block finding(s)`                        | A hard-block gate fired (only possible once identity-expanded gates ship)                     | Review `campaign_safety_audit_findings` for that audit id and resolve the flagged recipients before retrying             |
| Launch throws `recipient set changed since audit ...; rerun preflight` | Recipients were added/changed after the audit ran                                             | Re-run the preflight against the current queued set, then retry launch                                                   |
| Launch throws `... or live recipient set is empty`                     | Either the audit covered zero recipients or the campaign currently has zero queued recipients | Confirm the campaign has queued recipients; re-run the preflight against a non-empty set                                 |
| DNC findings exist but launch still succeeds                           | Expected — DNC is not a `hard_block` severity                                                 | Review the findings manually; dispatch separately re-checks consent at send time and skips opted-out contacts regardless |

## See also

* [Broadcast Engine](/operations/broadcasts) — where the preflight fits into a campaign's launch lifecycle
* [Futura Campaign Go-Live Testing](/operations/futura-campaign-testing) — the existing manual preflight checklist this system is designed to eventually formalize
* [Platform → Data Model](/platform/data-model) — workspace-scoped table conventions
