# Sequence versioning for in-flight contacts

- **Pattern:** `ab-000602` (`campaign-orchestration-sequencing.sequence-architecture.sequence-versioning`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000602
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000602)

## Why it matters

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.

## Severity rationale

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.

## Remediation

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.

```ts
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.

## Detection

- **ID:** `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:

  ```ts
  // 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' }
    })
  }
  ```

## External references

- cwe CWE-440
- iso-25010 reliability.recoverability

Taxons: data-integrity

HTML version: https://auditbuffet.com/patterns/ab-000602
