Sequence
A multi-step outbound sequence in Pact: how steps, variants, channels, and idempotent deliveries are modeled and rendered.
Sequence
A sequence is an ordered, multi-step outbound cadence attached to enrolled subjects (companies or contacts). Each step declares a channel action — email, SMS, a manual LinkedIn touch, a call, a task, or a wait — and the sequence engine renders and dedupes deliveries as enrollments advance.
Sequences live under the sales module. Every route is gated by
require_module("sales") and authenticated with an API key, so a tenant
without the sales module gets a clean 403 rather than a partial view.
API surface
The router is mounted at /v1/sequences (api/routes/sequences.py):
| Method | Path | Purpose |
|---|---|---|
GET/POST | /v1/sequences | list / create sequences |
GET/PATCH/DELETE | /v1/sequences/{seq_id} | fetch / update / delete |
GET/POST | /v1/sequences/{seq_id}/steps | list / add steps |
PATCH/DELETE | /v1/sequences/{seq_id}/steps/{step_id} | update / delete a step |
GET/POST | /v1/sequences/{seq_id}/enrollments | list / enroll a subject |
A sequence is persisted as one sequences row plus N sequence_steps rows;
enrollments are tracked separately and deliveries land in
sequence_deliveries.
Template rendering
Step bodies are rendered by sequence_engine.rendering.render_template. The
renderer is deliberately minimal and safe for outbound messaging:
- Only
{{ variable_name }}substitution is supported — no expression evaluation, no attribute traversal, no code execution. - A variable that is missing (or resolves to an empty value) is left in
place as its original
{{var}}token so a human can see what would fail before a real send. - Every unresolved token is reported in
RenderResult.missing_vars.
from sequence_engine.rendering import render_template
render_template("Hi {{ first_name }} at {{ company }}", {"first_name": "Dana"})
# RenderResult(text="Hi Dana at {{ company }}", missing_vars=("company",))
Channels
sequence_engine.outbox.canonical_channel maps a step's action type to a
stable delivery channel slug. Channels split into two sets:
SENDABLE_CHANNELS = {"email", "sms"}— Pact can dispatch these itself.MANUAL_CHANNELS = {"linkedin", "call", "task", "wait", "wait_reply", "branch"}— surfaced to a human or handled as control flow rather than an automated send.
A/B variants and idempotency
Two pure helpers in sequence_engine.outbox keep sends reproducible and
exactly-once:
choose_variant(...)deterministically buckets an enrollment+step into variant"A"or"B"by hashingsha256("{company_sequence_id}:{step_id}:variant")against the configured split percentage. If no Variant B body exists it always returns"A", so retries never reassign copy.build_idempotency_key(...)produces a SHA-256 dedupe key over"{tenant_id}|{company_sequence_id}|{sequence_step_id}|{variant}". The delivery writer checks this key againstsequence_deliveriesbefore inserting, so a re-run of the same enrollment-step-variant never double-sends.
Enrollment == company_sequence_id
In the delivery layer, an enrollment is identified by
company_sequence_id. That is the value threaded through both the
variant hash and the idempotency key, which is why re-processing an
enrollment is stable across retries.