pex

Dry-run + staged rollout

Apex gives you three confidence levels before a journey reaches your full audience:

  1. Dry-run — trace one user through the journey without sending anything.
  2. Owner-overrides — temporarily bypass opt-out / suppression / cap gates in a dry-run (audited).
  3. Staged rollout — publish, but route only a percentage of the audience until you're satisfied (deferred to Phase 1c — see below).

This guide covers the first two — the surfaces shipping today — and explains the path to staged rollout.

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 decisionexecuted / 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 {{firstName}} 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. For adaptive branches, the trace records both the cold-start round-robin position and the Thompson posterior sample.
  • 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 goal-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 user who should match the audience. Confirm the journey traces all the way to a Send step that fires.
  2. Run dry-run against a real user who should NOT match the audience. Confirm the journey exits early at the trigger or audience filter.
  3. Run dry-run against an opted-out user (or use the override). Confirm the send step skips with reason opt_out.
  4. If the journey has an adaptive branch, run dry-run twice with different synthetic subject IDs and confirm the cold-start arm assignment differs (the deterministic FNV-1a hash should produce different arms for different IDs).

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

Staged rollout (Phase 1c)

The runtime supports staged rollout — the trigger evaluator can match an audience predicate that includes a random_assignment_below_pct clause — but the operator UI for it is intentionally deferred to Phase 1c. Reasoning:

  • Workspace-scoped global holdout (always-on at 5% by default) gives you the production-traffic measurement you need without manually staging rollouts. Most journeys ship at 100% with the holdout doing the lift attribution.
  • A staged rollout UI without a very clear UX for "what does 25% rollout actually mean alongside the holdout" tends to confuse operators.
  • Phase 1c, when we ship the transactional API surface, will reuse the same staged rollout primitive with proper UX framing.

If you have a regulated rollout requirement before then (e.g. legal needs you to ship a journey to one cohort first, then expand), DM us — we can implement it as an audience predicate manually.

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.