Dry-run + staged rollout

Apex gives you two confidence levels before a journey reaches everyone who matches:

  1. Dry-run — trace one person through the journey without sending anything.
  2. Owner-overrides — temporarily bypass opt-out / suppression / cap gates in a dry-run (audited).

There is no staged-rollout control in the publisher today. The workspace holdout is the production measurement. To ship to one Segment first, build that Segment and attach it to the journey.

Why this matters

The same eligibility evaluator and step planners run in production AND in dry-run. That parity is constructed — both paths import from app/src/lib/journeys/eligibility/ and app/src/lib/journeys/executors/. There's no "test mode" that bypasses anything except the actual SES/in-app/push send call. If a step skips in dry-run, it will skip in production for the same user.

This means you can find every "why didn't my email land?" before publishing, not after.

Dry-run: the basics

From the journey detail canvas, click Dry run in the toolbar. The Dry-run panel slides open from the right.

Pick a subject

Two ways:

  1. Real end-user — search by email or pick from "recent users (last 7 days)". Apex pulls the actual subject snapshot (cohorts, attributes, recent events) and threads it through the journey.
  2. Synthetic profile — type a fake email + key/value attributes. Useful for testing edge cases ("user with no firstName", "user already in the holdout") without polluting your live data.

Run the simulation

Click Run simulation. Apex traces the journey step by step and renders each one with:

  • Step ID + type badge.
  • Eligibility decision — executed / skipped / blocked with the precise reason (opt_out, suppression_match, frequency_cap_exceeded, daily_cap_exceeded, holdout_membership).
  • Liquid render output for send steps — subject + body with {{first_name}} etc. resolved against the subject's actual attributes. PII is masked in the output (emails show as j***@example.com).
  • Branch decision for branch steps — which arm fired and why (the rule, or the engagement signal).
  • Wait simulation — for wait_duration and wait_for_event steps, the simulator records what the wait would do without actually waiting. Helpful for checking the next step a wait routes to.

The trace appears as a vertical step-by-step list with the same step IDs you see on the canvas. Click a row to expand its details.

What dry-run does NOT do

  • It does not call SES, in-app push, or mobile push. Zero outbound traffic, zero hits on your sending reputation.
  • It does not write to JEXEC# / JIMPACT# / JCAP#. No metering counters bump.
  • It does not run conversion attribution. That runs against a real execution + a real attribution window.
  • It does not put the subject in a holdout. Even if the subject would be assigned to the holdout in production, dry-run shows you what they'd see if they weren't.

Owner-overrides: bypassing gates intentionally

Sometimes you need to verify "what would the welcome email look like for this opted-out user?" Dry-run gives workspace owners the ability to override eligibility gates one at a time.

At the top of the Dry-run panel, four toggles (visible only to owners):

  • Bypass opt-out — ignores EndUserCommPreferences.globalOptOut + per-comm opt-out flags.
  • Bypass suppression — ignores SES bounce / complaint suppression list match.
  • Bypass frequency caps — ignores per-user daily / weekly send caps.
  • Bypass holdout — treats the subject as if they weren't in the global holdout.

Every toggle emits a dry-run-override-applied audit event with metadata { overrides: ["opt_out", "frequency_cap"] }. The audit timeline shows you which overrides were applied to which dry-run, by whom, when. Auditors / compliance reviewers can replay the timeline to confirm overrides were intentional, scoped, and not used to send to opted-out users.

Owners are the ONLY role with override access. Workspace admins can run dry-runs but not toggle the overrides.

Before clicking Publish on any new journey, our suggested ritual:

  1. Run dry-run against a real person who should match the Segment. Confirm the journey traces all the way to a Send step that fires.
  2. Run dry-run against a real person who should NOT match the Segment. Confirm the journey exits early at the trigger or Segment filter.
  3. Run dry-run against an opted-out person (or use the override). Confirm the send step skips with reason opt_out.
  4. If the journey has an Experiment step, run dry-run twice with different synthetic IDs and confirm the two people can land on different letters.

Five minutes of dry-running prevents days of "wait, why is this person getting two emails?" debugging in production.

Shipping to a slice first

The workspace holdout (5% by default) already keeps a do-nothing group so you can read lift. If legal needs the journey to start on one group only, attach that Segment and publish. Widen the Segment when you are ready. There is no percentage-slider publish control.

Common questions

"I keep getting holdout_membership for the user I picked"

Not a bug. The global holdout is workspace-deterministic — the same user is held out across every journey in the workspace, by design (so journey effects don't compose chaotically). To dry-run-render their experience, toggle the Bypass holdout override.

"My dry-run trace shows the send step but no actual email arrives"

Dry-run intentionally doesn't send. To verify the actual delivery path, publish the journey, fire a synthetic event matching the trigger, and watch the live executions panel. Email lands within ~30s of the send step executing.

"Can I dry-run against a hypothetical user who doesn't exist yet?"

Yes — use the synthetic profile mode. Type any email + attributes. Apex won't write the synthetic user to your data store; it stays in-memory for the trace.

"Does dry-run consume my workspace event budget?"

No. Dry-run doesn't touch /api/events; it queries the existing data store directly to assemble the subject snapshot.