Skip to main content
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.
The preflight is off by default. Existing campaigns launch exactly as before unless a campaign’s metadata explicitly opts in via launchSafety.requireSafetyPreflight.

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
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.

Severity ladder

Findings carry one of seven severities, from most to least serious:
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.

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.

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.
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.

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. campaign_safety_audit_findings — N rows per audit, one per (recipient, finding type) hit.

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.
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.

Triage

See also