When a sequence is edited in place — steps added, removed, or reordered on the same mutable record — every active enrollment instantly changes behavior. A contact on step 3 of a 5-step sequence wakes up to find step 3 is now something completely different, or that step 5 was removed and the sequence loops. CWE-440 (Expected Behavior Violation) and iso-25010:2011 reliability.recoverability apply: the system makes no guarantee about what a contact in-flight will experience once a sequence is modified. Beyond the technical defect, this creates unpredictable outreach — contacts who opted into a specific message cadence receive a different one — and debugging post-send complaints becomes impossible without version history.
High because in-place edits silently corrupt in-flight enrollments, potentially skipping or repeating content for contacts mid-sequence with no way to audit what changed.
Snapshot sequence steps into an immutable sequence_versions table on each publish and store the version ID on enrollment records. New enrollments reference the current active version; existing enrollments are unaffected by subsequent edits.
async function publishSequenceVersion(sequenceId: string, steps: SequenceStep[]) {
const version = await db.sequenceVersion.create({
data: { sequenceId, steps: JSON.stringify(steps), publishedAt: new Date() }
})
await db.sequence.update({ where: { id: sequenceId }, data: { activeVersionId: version.id } })
return version
}
// Enrollment records the version at enroll time
async function enrollContact(contactId: string, sequenceId: string) {
const sequence = await db.sequence.findUnique({
where: { id: sequenceId }, include: { activeVersion: true }
})
return db.contactSequence.create({
data: {
contactId, sequenceId,
sequenceVersionId: sequence.activeVersionId,
currentStepIndex: 0, status: 'active'
}
})
}
The sequence_versions table and sequence_version_id FK on contact_sequences are the minimum schema changes required.
ID: campaign-orchestration-sequencing.sequence-architecture.sequence-versioning
Severity: high
What to look for: When a sequence is edited (steps added, removed, or reordered), contacts currently active in that sequence should continue on the version they enrolled in — not be silently migrated to the new version mid-flight. Look for: a version field on sequence definitions, enrollment records that store which version a contact enrolled in, or immutable sequence copies when a sequence is published. Red flags: editing a sequence's steps in place with no versioning, a single mutable sequence record that all active enrollments point to.
Pass criteria: Sequence definitions are versioned. In-flight contacts remain on their enrolled version when a sequence is updated. A new version is created (or changes are queued to apply only to new enrollments). Count all version-related columns or fields in the schema — at least 1 version identifier must exist on enrollment records. Report the count even on pass.
Fail criteria: Sequences are edited in place. Active contacts are immediately affected by any step changes, including step deletions that could skip or repeat content.
Skip (N/A) when: The project has no sequence functionality, or sequences are short enough (1-2 steps) that in-flight migration is trivial.
Detail on fail: "Sequence steps are stored in a single mutable array — editing a sequence immediately affects all active enrollments" or "No version field on sequence or enrollment records"
Remediation: Snapshot sequence definitions on publish and reference the snapshot from enrollments:
// When publishing a new sequence version
async function publishSequenceVersion(sequenceId: string, steps: SequenceStep[]) {
const version = await db.sequenceVersion.create({
data: { sequenceId, steps: JSON.stringify(steps), publishedAt: new Date() }
})
await db.sequence.update({ where: { id: sequenceId }, data: { activeVersionId: version.id } })
return version
}
// Enrollment stores the version it enrolled on
async function enrollContact(contactId: string, sequenceId: string) {
const sequence = await db.sequence.findUnique({ where: { id: sequenceId }, include: { activeVersion: true } })
return db.contactSequence.create({
data: { contactId, sequenceId, sequenceVersionId: sequence.activeVersionId, currentStepIndex: 0, status: 'active' }
})
}