Duplicate enrollment — calling enrollContact() twice for the same contact and sequence — creates two active send threads. Both threads advance independently and both send on their own schedules, so a single contact can receive every sequence email twice. CWE-841 (Improper Enforcement of Behavioral Workflow) applies directly: the system fails to enforce that a contact occupies exactly one position in a sequence at a time. The exit-conditions gap is equally damaging: contacts who unsubscribe mid-sequence continue receiving steps because no exit path was wired to the opt-out signal, creating CAN-SPAM and GDPR Art-6 exposure for marketing emails sent after a withdrawal of consent.
High because missing deduplication causes duplicate send threads, and missing exit conditions continue sending to contacts who have unsubscribed — both are legally and reputationally damaging.
Add an idempotency guard on enrollment and explicit exit handlers for every termination reason. In src/lib/sequences/enrollment.ts (or equivalent):
async function enrollContact(contactId: string, sequenceId: string) {
const existing = await db.contactSequence.findFirst({
where: { contactId, sequenceId, status: { in: ['active', 'paused'] } }
})
if (existing) return { alreadyEnrolled: true }
return db.contactSequence.create({
data: { contactId, sequenceId, currentStepIndex: 0, status: 'active' }
})
}
async function exitContact(
contactId: string,
sequenceId: string,
reason: 'replied' | 'opted_out' | 'manual' | 'completed'
) {
await db.contactSequence.updateMany({
where: { contactId, sequenceId, status: { in: ['active', 'paused'] } },
data: { status: reason === 'completed' ? 'completed' : reason, exitedAt: new Date() }
})
await cancelPendingSteps(contactId, sequenceId)
}
At minimum, three exit reasons must be wired: completion, opt-out, and reply.
ID: campaign-orchestration-sequencing.sequence-architecture.entry-exit-conditions
Severity: high
What to look for: Check how contacts enter and exit sequences. Entry conditions should be defined (e.g., "enroll when lead score reaches 50", "enroll when contact signs up", "enroll when tag added"). Exit conditions should cover: completion of all steps, reply received, opted out, contact deleted, or manual removal. Look for: enrollment trigger logic, a way to remove contacts from active sequences, and guards that prevent enrolling a contact who is already active in the same sequence.
Pass criteria: Entry triggers are defined and documented in code. Exit conditions are explicit (completion, reply, opt-out, manual) — enumerate all exit conditions and count them, at least 3 distinct exit reasons must be handled. Duplicate enrollment in the same sequence is prevented.
Fail criteria: Contacts can be enrolled multiple times in the same sequence simultaneously. No explicit exit conditions — sequences only end by running out of steps. No deduplication check on enrollment.
Skip (N/A) when: The project has no sequence functionality.
Detail on fail: "No enrollment deduplication — calling enrollContact() twice creates two active enrollments for the same sequence" or "Sequences have no opt-out exit path — contacts who unsubscribe continue receiving steps"
Remediation: Add enrollment guards and explicit exit handling:
async function enrollContact(contactId: string, sequenceId: string) {
// Prevent duplicate active enrollment
const existing = await db.contactSequence.findFirst({
where: { contactId, sequenceId, status: { in: ['active', 'paused'] } }
})
if (existing) return { alreadyEnrolled: true }
return db.contactSequence.create({
data: { contactId, sequenceId, currentStepIndex: 0, status: 'active' }
})
}
async function exitContact(contactId: string, sequenceId: string, reason: 'replied' | 'opted_out' | 'manual' | 'completed') {
await db.contactSequence.updateMany({
where: { contactId, sequenceId, status: { in: ['active', 'paused'] } },
data: { status: reason === 'completed' ? 'completed' : reason, exitedAt: new Date() }
})
// Cancel any pending jobs for this contact in this sequence
await cancelPendingSteps(contactId, sequenceId)
}