Repository navigation
feat(sequences): conditional branching for campaign steps - #242
Conversation
aca19dd to
940b1af
Compare
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
940b1af to
2df60f9
Compare
Rebased onto
|
| 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
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:
always,opened,clicked,replied,not_openedornot_replied, within N days (1–90), optionally narrowed to a reply label.The data lives in a new
sequence_step_branchestable. Composite FKs make an exit into another campaign or tenant unrepresentable.API
/campaigns/{id}/graph/campaigns/{id}/steps/{stepId}/branch/campaigns/{id}/steps/{stepId}/branchErrors come back as
BranchValidationErrorwith a code;cyclealso 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
inbox_messages, the other leg, and exclude auto-replies unless the branch names that label.reply_label_stops_sequence). The API docs say every default human label stops the sequence.tracking_required).branchroute.go, including one narrow recover-forward edge.Migrations
…110214adds the table, the enrollment column and a unique constraint.…144758adds the inbox_threads index as a single-statementCREATE INDEX CONCURRENTLY, so the send path isn't blocked. Itsindisvalidis asserted on a scratch DB.ON DELETE SET NULL), and the deploy docs now say so.Verification
-tags=integration), golangci-lint v2.12.2, unit tests, and integration tests (99 packages).lint:apiis valid.internal/platform/storagefails 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.mdinvariant 82 is also claimed by the audit, retention and inbox-search branches. Whichever merges later renumbers.🤖 Generated with Claude Code