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 thethenorelseedge.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.
Consent is enforced on every send
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:
- Writes a delivery row with
status='blocked_consent'(the legal reason is preserved inlast_error, e.g.consent.<reason>). - Logs a
journey_runsrow withoutcome=blocked_consent. - Exits the enrollment with
exit_reason='consent_withdrawn'— so Pact stops contacting a subject who has revoked consent mid-journey.
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.