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:
- Resolves the campaigns in scope — either every campaign sharing a
batchKey(a campaign “family,” matched viacampaigns.metadata->>'batchKey'), or an explicit list of campaign ids. - Loads the queued
campaign_recipientsfor those campaigns, joined to the safety-relevant contact columns (consent_state,subscriber_status,deleted_at,deletion_requested_at,metadata). - Opens an audit row (
campaign_safety_audits, statusrunning). - Runs every registered deterministic gate over the recipient set and collects findings.
- Persists the findings (
campaign_safety_audit_findings) and completes the audit — writing arecipient_set_hashover the sorted recipient ids and the rolled-up severity counts, then flips status tocompleted. - On any error after the audit row opens, the audit is marked
failedwith 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 isdncGate — 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_atset - the contact has
deletion_requested_atset
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: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:
- A completed audit exists for this campaign’s
batchKey(or campaign id, if no batch key). - That audit’s
hard_block_countis exactly zero. - The audit’s
recipient_set_hashmatches a live hash recomputed over the campaign’s current queued recipients at launch time.
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
Therecipient_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
requireSafetyPreflightenable 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
batchKeyaudit’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 (viaapp.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.
Triage
See also
- Broadcast Engine — where the preflight fits into a campaign’s launch lifecycle
- Futura Campaign Go-Live Testing — the existing manual preflight checklist this system is designed to eventually formalize
- Platform → Data Model — workspace-scoped table conventions