Skip to content

feat(sequences): conditional branching for campaign steps - #242

Merged
Ahmustufa merged 6 commits into
mainfrom
feature/sequence-branching
Sep 24, 2026
Merged

Ahmustufa merged 6 commits into
mainfrom
feature/sequence-branching

Conversation

@Ahmustufa

Copy link
Copy Markdown
Contributor

Backend for the "[Backend] Conditional branching model for sequence steps" Asana task, the capability half of the campaign canvas (#238). The canvas's condition nodes are wired next.

Model

A step can carry one branch that routes each enrollment after that step is sent:

  • Condition: always, opened, clicked, replied, not_opened or not_replied, within N days (1–90), optionally narrowed to a reply label.
  • Exits: a yes exit and a no exit. A null exit ends the path.
  • Branchless campaigns keep the original linear code path unchanged, and a test pins that.

The data lives in a new sequence_step_branches table. Composite FKs make an exit into another campaign or tenant unrepresentable.

API

Method Path
GET /campaigns/{id}/graph
PUT /campaigns/{id}/steps/{stepId}/branch
DELETE /campaigns/{id}/steps/{stepId}/branch

Errors come back as BranchValidationError with a code; cycle also lists the steps in the loop. Cycles are refused at save time under a per-campaign lock, and that includes step delete and reorder.

Semantics

  • Evidence comes from a fixed window measured from the step's own send, so a decided answer can't flip.
  • Opens and clicks count human events only.
  • Replies use inbox_messages, the other leg, and exclude auto-replies unless the branch names that label.
  • Stop-on-reply wins (product decision). A branch naming a stopping label is refused (400 reply_label_stops_sequence). The API docs say every default human label stops the sequence.
  • Tracking: open and click conditions are refused without tracking or an HTML body (400 tracking_required).
  • Paused and done campaigns neither route nor park enrollments.
  • Suppression still applies on routed sends, with a test.
  • Mid-flight edits use the graph as it is when each decision is made. The rules are documented in branchroute.go, including one narrow recover-forward edge.

Migrations

  • …110214 adds the table, the enrollment column and a unique constraint.
  • …144758 adds the inbox_threads index as a single-statement CREATE INDEX CONCURRENTLY, so the send path isn't blocked. Its indisvalid is asserted on a scratch DB.
  • Postgres 15+ is required (column-list ON DELETE SET NULL), and the deploy docs now say so.

Verification

  • Go: build, vet (plus -tags=integration), golangci-lint v2.12.2, unit tests, and integration tests (99 packages).
  • The routing tests include real poll → classify → dispatch reply tests.
  • Mutation checks: each of 23 guards was broken in turn, and its test failed every time.
  • lint:api is valid.
  • Code review: approve with nits. Security review: OK to merge. Every major, minor and security item is fixed in the third commit.
  • internal/platform/storage fails locally with Windows file locks. It isn't touched here.

Merge order

Please merge this before the data-retention PR. Retention rolls up old tracking events, and it will then switch this branch's open/click evidence query to the rolled-up view. Otherwise an "opened within 60 days" branch would miss opens older than the retention window.

Note: security.md invariant 82 is also claimed by the audit, retention and inbox-search branches. Whichever merges later renumbers.

🤖 Generated with Claude Code

@Ahmustufa
Ahmustufa force-pushed the feature/sequence-branching branch from aca19dd to 940b1af Compare September 23, 2026 20:21
Ahmustufa added a commit that referenced this pull request Sep 24, 2026
#239 (audit log) and #240 (inbox search) each added an invariant 82.
The audit log keeps 82. Branching (#242) takes 84, and retention takes
the next free number.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ahmustufa and others added 6 commits September 24, 2026 15:20
A step can now carry one branch that routes each enrollment after the step
is sent: always / opened / clicked / replied / not_opened / not_replied,
within N days (1-90), optionally narrowed to a reply label, with a yes exit
and a no exit (a null exit ends the path). Steps without a branch keep the
original linear code path unchanged, and a test pins that.

- New table sequence_step_branches, one row per source step. Composite FKs
  keep both exits in the same campaign and tenant. Deleting a target step
  nulls only that exit.
- The route is recomputed from events inside a fixed window measured from
  the step's own send, so a decided answer can't flip. Opens and clicks
  count human events only. Replies look at inbox_messages (the other leg)
  and exclude auto-replies unless the branch names that label.
- Cycles are refused at save time under a per-campaign lock, including for
  step delete and reorder. A runtime backstop ends the path if a loop ever
  gets in.
- Mid-flight edits use the graph as it is when the decision is made. The
  rule is documented in branchroute.go.
- GET /campaigns/{id}/graph, PUT/DELETE /campaigns/{id}/steps/{stepId}/branch.
- Threading replies to the most recently sent email, not the
  highest-numbered step, since a path can go 1 -> 3 -> 2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Reply branches (product decision: stop-on-reply stays, so labels win):
- A reply branch on a label that stops the sequence is refused at save
  (400 reply_label_stops_sequence). The API says every default human label
  stops, so a default-label "replied" branch never fires. The replacement
  tests go through the real poll -> classify -> dispatch path.
- A reply nudge only pulls forward a due time that a condition wait set,
  never an out-of-office deferral.

Routing:
- The loop backstop compares against the current step's own send row, not
  enrollment.last_sent_at, which a recover-forward re-stamps.
- Paused and done campaigns neither finish nor park enrollments; the
  status gate now runs before routing.
- opened/clicked/not_opened are refused without tracking or an HTML body
  (400 tracking_required).
- Condition waits no longer count as "deferred" sends.
- Only a real miss is a 404.
- LatestSentForContact is workspace-pinned.
- New test: a routed send still honours suppression.

Migration: the inbox_threads index is its own single-statement
CONCURRENTLY migration (asserted indisvalid on a scratch DB), so the
branching migration no longer blocks the send path.

Docs: Postgres 15+ minimum; mid-flight edit rules corrected; invariant 82
notes that reply evidence is thread-scoped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
main now has the audit log at 82 and inbox search at 83 (#252).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…lds can't deadlock

golang-migrate's pgx5 driver waits for its advisory lock inside a
running statement, which holds a snapshot. CREATE INDEX CONCURRENTLY
waits for every snapshot, so a second migrator waiting on the lock
deadlocks the build, and the migration is left dirty. This reproduced 3
of 3 times with 6 concurrent migrators on a fresh DB, and it hits both
CI (-p 4) and multi-replica deploys.

Migrate, MigrateDown, MigrateTo and Version now take a separate
session-level lock by polling pg_try_advisory_lock on a dedicated
connection. A waiter is idle between tries and holds no snapshot. The
wait is bounded (15 min, ErrMigrationLockTimeout), and unlock/close run
on a detached context.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#253 landed invariants 83-85 (the mail transport seam, AUTH negotiation, EHLO
validation) while this branch was open, so its own 84 collided. Renumbered to
86 and re-pointed the five code comments that cited it.

That last part is the half a textual merge does not catch: main's 84 is now
"IMAP and SMTP authentication negotiate a mechanism", so every comment here
still saying "invariant 84" would have pointed a reader at an unrelated
invariant while reading as correct. This repo has already lost a Critical to a
comment whose attribution outlived its truth.

stepbranch.sql.go is regenerated from its .sql source rather than hand-edited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VECELVAKe8Xcp7GH9wGR5t
@Ahmustufa
Ahmustufa force-pushed the feature/sequence-branching branch from 940b1af to 2df60f9 Compare September 24, 2026 10:25
@Ahmustufa

Copy link
Copy Markdown
Contributor Author

Rebased onto 5bf02ca — conflict resolved

One conflict, in docs/security.md, with a second half a textual merge does not catch.

The collision. #253 (IMAP/SMTP hardening) merged while this branch was open and took invariants 83, 84 and 85 — the mail transport seam, AUTH negotiation, and EHLO validation. This branch's own invariant was also numbered 84. Both sides are additive, so main's three are kept and this branch's becomes 86. The sequence now runs 80 → 86 with no gap or duplicate.

The half that mattered more. Five code comments cited invariant 84 meaning this branch's invariant:

internal/app/sequencestep/branch.go:111
internal/platform/db/queries/stepbranch.sql:60
internal/platform/db/gen/stepbranch.sql.go:69
internal/coreapi/inprocess/branchroute.go:350
internal/worker/sequence/branching_integration_test.go:176

After the renumber, main's 84 is "IMAP and SMTP authentication negotiate a mechanism" — so every one of those would have pointed a reader at an unrelated invariant while reading as perfectly correct. All five re-pointed at 86. This repo has already lost a Critical through four review rounds to a comment whose attribution outlived its truth; that is the same shape.

stepbranch.sql.go was regenerated from its .sql source rather than hand-edited.

Verification after the rebase

Gate Result
sqlc generate regenerated; committed output matches
go build / go vet / go vet -tags=integration clean
gofmt -l internal cmd empty
golangci-lint 2.12.2 (pinned, cache cleaned) 0 issues
TZ=UTC go test -race -count=1 -p 4 -tags=integration ./... 102 packages, 0 FAIL, 0 races

Branching packages specifically: app/sequencestep ok, platform/seqgraph ok, worker/sequence ok.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VECELVAKe8Xcp7GH9wGR5t

@Ahmustufa
Ahmustufa merged commit b5d2571 into main Sep 24, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant