Repository navigation
Conversation
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>
Ahmustufa
force-pushed
the
feature/sequence-branching
branch
from
September 23, 2026 20:21
aca19dd to
940b1af
Compare
PUT /branch takes an optional expected_updated_at: absent means last writer wins (as before), null means create-only, and a timestamp means replace only if unchanged. DELETE takes it as a query param. A mismatch returns 409 branch_changed with the current branch (or null). It is enforced atomically in SQL under the existing per-campaign graph lock. Two latent bugs made the token unusable, and both are fixed: - updated_at was serialized to whole seconds (RFC3339), so it could never match exactly. It is now RFC3339Nano (microseconds). - updated_at did not always advance. now() is the transaction start (taken before the lock), two writes in one tx collided, and ON DELETE SET NULL left it untouched. A trigger now sets greatest(clock_timestamp(), old + 1us). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ahmustufa
force-pushed
the
feature/branch-write-precondition
branch
from
September 23, 2026 20:23
05b4d68 to
66d904f
Compare
Ahmustufa
marked this pull request as draft
September 23, 2026 20:27
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #242 (branching). Merge that first; this PR's base then moves to
main.Closes the cross-tab last-writer-wins gap that the #245 review found.
Contract
/campaigns/{id}/steps/{stepId}/branch, optionalexpected_updated_at:null: create-only;expected_updated_atquery param.{ error, code: "branch_changed", current: StepBranch | null }.StepBranch.updated_atnow carries microseconds. Clients must echo it verbatim, because a JSDateround trip truncates it to milliseconds.Latent bugs fixed
updated_atwas serialized to whole seconds, so it could never match exactly.updated_atdidn't always advance:now()is the transaction start time, taken before the lock;ON DELETE SET NULLleft it untouched.A trigger now makes it strictly increase.
Verification
-tags=integration), golangci-lint v2.12.2, and unit and integration tests. The integration tests cover:updated_atadvancing across 20 writes, within one transaction, and on SET NULL.now(), and reverting the handler to whole seconds each make a test fail.lint:apiis valid.The frontend side (sending the token, handling 409) lands in #245.
🤖 Generated with Claude Code