PPactDocs
Glossary

Journey

A versioned, graph-based marketing journey in Pact: DAG step kinds, version pinning, the scheduler tick, and the consent + frequency-cap gate on every send.

Journey

A journey is a versioned, graph-based automation defined in core/journeys.py and exposed under /v1/journeys (api/routes/journeys.py). Where a sequence is a linear cadence, a journey is a directed graph: subjects enter, and the engine walks nodes until they hit a wait or exit.

Step kinds

A journey definition is a map of node id → node. The core DAG step kinds are:

  • send — write a delivery row through the consent gate, then advance.
  • wait — pause until a duration elapses, then resume on the next tick.
  • branch — evaluate a predicate and follow the then or else edge.
  • exit — terminate the enrollment.

More kinds exist

The engine also handles additional node kinds (for example fetch, decision, social_touch, update, and send_time_optimize) as the marketing surface has grown. send / wait / branch / exit are the canonical building blocks and the ones every journey graph is expressed in.

Versioning — live edits don't break in-flight subjects

Definitions are stored as JSON (the definition_json column) on journey_definitions. Each enrollment is pinned to its definition_id, so editing a journey never mutates subjects already moving through it — they finish on the version they started on. New enrollments pick up the current version, tracked by journeys.current_version_id (set on create and promote_definition).

The scheduler tick

JourneyService.tick is the heartbeat. Each tick selects active enrollments whose scheduled_at is due and, for each one, walks the graph from its current node until it reaches a wait (re-scheduled for later) or an exit (terminated). Every executed step writes a journey_runs row, so the walk is fully auditable.

This is the consent-native part of Pact and it is not optional. Inside _exec_send, before any delivery row is written, the step calls core.consent (consent_service.gate). When consent allows the send, a frequency-cap check runs via core.frequency_caps (frequency_cap_check).

A blocked send does three things:

  1. Writes a delivery row with status='blocked_consent' (the legal reason is preserved in last_error, e.g. consent.<reason>).
  2. Logs a journey_runs row with outcome=blocked_consent.
  3. Exits the enrollment with exit_reason='consent_withdrawn' — so Pact stops contacting a subject who has revoked consent mid-journey.
text
send node ──▶ consent_service.gate ──┐
                                     ├─ allowed?  ──▶ frequency_cap_check ──▶ queued delivery ──▶ next
                                     └─ blocked?  ──▶ blocked_consent row + journey_runs(blocked_consent) ──▶ exit(consent_withdrawn)

Cap vs consent are distinct

A frequency cap is a governance event, not a legal one: a capped send writes status='frequency_capped' for the audit trail but the enrollment keeps advancing. A consent block is terminal.