diff --git a/api/openapi.yaml b/api/openapi.yaml
index 9ec1f41d..fe515f1a 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -2097,6 +2097,12 @@ paths:
'401': { description: Unauthorized }
'404': { description: Campaign or step not found }
'409': { description: Campaign not draft (delete is a structural edit) }
+ '422':
+ description: >-
+ Deleting the step would close a loop: the step before it falls
+ through to the step after it, and a branch leads back (code cycle).
+ Branch exits that pointed AT a deleted step become path ends.
+ content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } }
/campaigns/{id}/steps/{stepId}/variants:
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
@@ -2238,6 +2244,140 @@ paths:
'401': { description: Unauthorized }
'404': { description: Campaign or step not found }
'409': { description: Campaign not draft (reorder is a structural edit) }
+ '422':
+ description: "The new order's fall-through edges would close a loop through a branch (code cycle)"
+ content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } }
+ /campaigns/{id}/graph:
+ get:
+ operationId: getCampaignGraph
+ tags: [campaigns]
+ security: [{ bearerAuth: [] }]
+ parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
+ description: >-
+ The campaign's sequence as a routing graph: one node per step (in
+ step_order), each with its branch (if any) and the step it falls
+ through to when it has none. A campaign with no branches is the linear
+ sequence - every node has branch null and falls through to the next step
+ by step_order, and the last one ends.
+
+
+ Edges to draw: for a node WITH a branch, its yes_step_id / no_step_id
+ (null = the path ends there; 'always' has only yes_step_id). For a node
+ WITHOUT one, default_next_step_id (null = the path ends).
+ responses:
+ '200': { description: The graph, content: { application/json: { schema: { $ref: '#/components/schemas/CampaignGraph' } } } }
+ '400': { description: Campaign id is not a uuid }
+ '401': { description: Unauthorized }
+ '403': { description: "Insufficient scope (campaigns:read)" }
+ '404': { description: Campaign not found }
+ /campaigns/{id}/steps/{stepId}/branch:
+ parameters:
+ - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
+ - { name: stepId, in: path, required: true, schema: { type: string, format: uuid } }
+ put:
+ operationId: setStepBranch
+ tags: [campaigns]
+ security: [{ bearerAuth: [] }]
+ description: >-
+ Create or replace the branch that routes an enrollment OUT of this step
+ after it has been sent. One branch per step.
+
+
+ Allowed on a RUNNING campaign (like a step content or variant edit).
+ Every routing decision uses the graph as it is when the decision is
+ made: a contact already waiting on this step is evaluated against the
+ new branch at its next check (at most an hour away), with the window
+ still measured from when this step was sent. Messages already sent are
+ never affected.
+
+
+ Conditions are evaluated against THIS step's send. opened / clicked /
+ not_opened count HUMAN tracking events only (machine prefetches are
+ ignored), so they are refused with 400 (code tracking_required) unless
+ the campaign has tracking on and the step and every one of its variants
+ has an HTML body.
+
+
+ replied / not_replied only ever route on replies whose reply label does
+ NOT stop the sequence. Reply labels decide what a reply does, and a
+ branch never overrides them: a reply whose label has
+ stops_enrollment=true - which by default is EVERY human label
+ (Interested, Not interested, Neutral, Unclassified, Unsubscribe) -
+ stops the sequence the moment it arrives, before any branch is
+ consulted. So with the default labels a replied branch never fires; to
+ route on replies, turn stops_enrollment off on the label (or create one
+ that does not stop) and name it in reply_label_key. Naming a stopping
+ label is refused with 400 (code reply_label_stops_sequence). Without a
+ label, replied counts any non-automated reply that leaves the sequence
+ running; out-of-office and auto-replies count only when named.
+
+
+ Refused with 422 (code cycle) when the result would let any path revisit
+ a step - including a loop closed by another step's linear fall-through -
+ and with 422 (code unknown_target) when an exit names a step outside
+ this campaign.
+
+
+ Optimistic concurrency. Without expected_updated_at the write replaces
+ whatever branch the step has (last writer wins). With it, the write
+ applies only if the step is still as the client last saw it - null
+ means "the step has no branch" (create-only), a timestamp means "the
+ branch's updated_at is still exactly this". Otherwise nothing is written
+ and the response is 409 (code branch_changed) carrying the branch as it
+ is now in current (null if the step has none). The precondition is
+ checked after the body is validated.
+ requestBody:
+ required: true
+ content: { application/json: { schema: { $ref: '#/components/schemas/StepBranchRequest' } } }
+ responses:
+ '200': { description: The saved branch, content: { application/json: { schema: { $ref: '#/components/schemas/StepBranch' } } } }
+ '400':
+ description: "Malformed or unusable branch (codes invalid_condition, invalid_within_days, invalid_reply_label, reply_label_stops_sequence, tracking_required, no_exit_not_allowed; cycle for an exit to the step itself). Invalid json or path ids return a plain {error} body."
+ content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } }
+ '401': { description: Unauthorized }
+ '403': { description: "Insufficient scope (campaigns:write)" }
+ '404': { description: Campaign or step not found }
+ '409':
+ description: "expected_updated_at no longer holds - the branch was created, replaced or removed since it was read (code branch_changed, with current)"
+ content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } }
+ '422':
+ description: "The graph refuses the edit (codes cycle, unknown_target)"
+ content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } }
+ delete:
+ operationId: deleteStepBranch
+ tags: [campaigns]
+ security: [{ bearerAuth: [] }]
+ description: >-
+ Remove the step's branch, returning it to linear fall-through (the next
+ step by step_order). Idempotent without expected_updated_at. Allowed on a
+ running campaign; a contact waiting on the removed condition proceeds to
+ the fall-through step, which still waits out its own delay_seconds from
+ this step's send. Refused with 422 (code cycle) if the restored
+ fall-through would close a loop.
+
+
+ With expected_updated_at the branch is removed only if its updated_at
+ is still exactly that value; otherwise 409 (code branch_changed) with
+ the branch as it is now in current - null when someone else already
+ removed it, which a client may treat as done.
+ parameters:
+ - name: expected_updated_at
+ in: query
+ required: false
+ description: "The branch's updated_at exactly as this API returned it (URL-encoded). Omit for an unconditional delete."
+ schema: { type: string, format: date-time }
+ responses:
+ '204': { description: Removed (or there was none) }
+ '400': { description: "expected_updated_at is not a timestamp this API could have returned" }
+ '401': { description: Unauthorized }
+ '403': { description: "Insufficient scope (campaigns:write)" }
+ '404': { description: Campaign or step not found }
+ '409':
+ description: "expected_updated_at no longer holds (code branch_changed, with current)"
+ content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } }
+ '422':
+ description: The restored fall-through would close a loop (code cycle)
+ content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } }
/campaigns/{id}/launch:
post:
operationId: launchCampaign
@@ -5041,6 +5181,74 @@ components:
type: array
description: the FULL ordered list of the campaign's step ids, in the desired order
items: { type: string, format: uuid }
+ StepBranchCondition:
+ type: string
+ enum: [always, opened, clicked, replied, not_opened, not_replied]
+ description: >-
+ What the branch tests, against the step it is attached to.
+ always - unconditional: go to yes_step_id (null = end the path).
+ opened / clicked - a HUMAN open/click of this step within within_days
+ of its send (yes as soon as it happens; no when the window closes).
+ replied - a counted reply within the window (yes as soon as it arrives).
+ not_opened / not_replied - the negation: no as soon as the event
+ happens, yes when the window closes without it.
+ StepBranch:
+ type: object
+ required: [step_id, condition, within_days, reply_label_key, yes_step_id, no_step_id, updated_at]
+ properties:
+ step_id: { type: string, format: uuid, description: The step this branch routes out of }
+ condition: { $ref: '#/components/schemas/StepBranchCondition' }
+ within_days: { type: integer, minimum: 1, maximum: 90, nullable: true, description: "Evaluation window in days after the step's send; null exactly when condition is always" }
+ reply_label_key: { type: string, nullable: true, description: "replied / not_replied only: count only replies classified with this reply label key (always a label that does not stop the sequence)" }
+ yes_step_id: { type: string, format: uuid, nullable: true, description: "Where a true condition (or always) goes; null ends the path" }
+ no_step_id: { type: string, format: uuid, nullable: true, description: "Where a false condition goes; null ends the path; always null for always" }
+ updated_at: { type: string, format: date-time, description: "When the branch last changed, at microsecond precision. Also its concurrency token - send it back verbatim as expected_updated_at (re-formatting through a millisecond clock such as a JS Date loses digits and never matches). Advances on every change, including an exit nulled because its target step was deleted." }
+ StepBranchRequest:
+ type: object
+ required: [condition]
+ properties:
+ condition: { $ref: '#/components/schemas/StepBranchCondition' }
+ within_days: { type: integer, minimum: 1, maximum: 90, nullable: true, description: "Required for every condition except always; must be absent or null for always" }
+ reply_label_key: { type: string, nullable: true, description: "Optional, replied / not_replied only. Must name a reply label key in the workspace whose stops_enrollment is false (a stopping label ends the sequence before any branch runs). Empty string is treated as null." }
+ yes_step_id: { type: string, format: uuid, nullable: true, description: "A step of the same campaign, not this step; null or absent ends the path" }
+ no_step_id: { type: string, format: uuid, nullable: true, description: "As yes_step_id; must be null or absent for always" }
+ expected_updated_at: { type: string, format: date-time, nullable: true, description: "Optional precondition. Absent - no check (last writer wins). null - apply only if the step has no branch. A timestamp - apply only if the branch's updated_at is still exactly this (the StepBranch.updated_at value verbatim). A failed check is 409 code branch_changed." }
+ CampaignGraphNode:
+ type: object
+ required: [step_id, step_order, default_next_step_id, branch]
+ properties:
+ step_id: { type: string, format: uuid }
+ step_order: { type: integer }
+ default_next_step_id: { type: string, format: uuid, nullable: true, description: "The next step by step_order - where this step goes when branch is null. Null for the last step. Present even when a branch overrides it." }
+ branch:
+ allOf: [{ $ref: '#/components/schemas/StepBranch' }]
+ type: object
+ nullable: true
+ description: "The step's branch, or null for linear fall-through to default_next_step_id."
+ CampaignGraph:
+ type: object
+ required: [campaign_id, entry_step_id, nodes]
+ properties:
+ campaign_id: { type: string, format: uuid }
+ entry_step_id: { type: string, format: uuid, nullable: true, description: "The first step (lowest step_order) every enrollment starts at; null for a campaign with no steps" }
+ nodes: { type: array, items: { $ref: '#/components/schemas/CampaignGraphNode' } }
+ BranchValidationError:
+ type: object
+ required: [error, code]
+ properties:
+ error: { type: string, description: Human-readable message }
+ code:
+ type: string
+ enum: [invalid_condition, invalid_within_days, invalid_reply_label, reply_label_stops_sequence, tracking_required, no_exit_not_allowed, unknown_step, unknown_target, cycle, branch_changed]
+ step_ids:
+ type: array
+ items: { type: string, format: uuid }
+ description: "code cycle only: the steps on the loop, in path order"
+ current:
+ allOf: [{ $ref: '#/components/schemas/StepBranch' }]
+ type: object
+ nullable: true
+ description: "code branch_changed only, and always present then: the step's branch as it is now, or null if it has none"
CreateCampaignRequest:
type: object
required: [name, mailbox_id, list_id, subject]
diff --git a/cmd/inroad/main.go b/cmd/inroad/main.go
index 7343047d..5e4886ae 100644
--- a/cmd/inroad/main.go
+++ b/cmd/inroad/main.go
@@ -521,7 +521,7 @@ func run() error {
// campaign status (draft-gating) via an adapter over the campaign store.
stepHandler := sequencestep.NewHandler(
sequencestep.NewService(sequencestep.NewPgStore(pool), campaignStatusChecker{campaigns: campaignStore},
- sequencestep.NewPgVariantStore(queries)),
+ sequencestep.NewPgVariantStore(queries), sequencestep.NewPgBranchStore(pool)),
cfg.JWTSecret,
)
// Deliverability guardrails. One service backs BOTH the API endpoints and the
diff --git a/docs/src/content/docs/deploy/aws-terraform.md b/docs/src/content/docs/deploy/aws-terraform.md
index bdc5ec81..2cbb4cd9 100644
--- a/docs/src/content/docs/deploy/aws-terraform.md
+++ b/docs/src/content/docs/deploy/aws-terraform.md
@@ -38,7 +38,7 @@ It is not, by itself, a multi-IP sending fleet.
## Architecture Infrastructure
- **VPC Subnets:** Public, Private App, and Private Database subnets across 2 Availability Zones with NAT Gateways.
-- **Managed Database:** Amazon RDS PostgreSQL 16 (Multi-AZ encrypted).
+- **Managed Database:** Amazon RDS PostgreSQL 16 (Multi-AZ encrypted). Keep `engine_version` at 15 or above: PostgreSQL **15 or newer is required**; 16 is what every bundled deployment runs and what CI tests against. The schema uses column-list `ON DELETE SET NULL (column)` foreign keys (the conditional-branching migration, `20260923110214_sequence_step_branches`), which PostgreSQL 14 and older reject, so migrations stop there on an older server.
- **In-Memory Cache:** Amazon ElastiCache for Redis cluster.
- **Container Compute:** AWS ECS Fargate Task Definitions & Services for API (`cmd/inroad`) and Worker (`cmd/worker`).
- **Load Balancing:** AWS Application Load Balancer (ALB) with HTTPS listener and `/healthz` health checks.
diff --git a/docs/src/content/docs/deploy/docker-compose.md b/docs/src/content/docs/deploy/docker-compose.md
index c45a4a1e..f697b79f 100644
--- a/docs/src/content/docs/deploy/docker-compose.md
+++ b/docs/src/content/docs/deploy/docker-compose.md
@@ -29,7 +29,7 @@ volume, not in the container's environment).
The root `docker-compose.yml` includes 7 services:
- `init-secrets`: One-shot service that generates a real random `INROAD_JWT_SECRET` and `INROAD_MASTER_KEY` into a Docker volume on first boot, so a bare `docker compose up` never runs on fixed, publicly-known secrets. Set both explicitly in the environment (or a `.env` file) to override — required for any multi-host deployment, since the generated file lives on a volume local to this host.
-- `postgres`: PostgreSQL 16 database.
+- `postgres`: PostgreSQL 16 database. PostgreSQL **15 or newer is required**; 16 is what every bundled deployment runs and what CI tests against. The schema uses column-list `ON DELETE SET NULL (column)` foreign keys (the conditional-branching migration, `20260923110214_sequence_step_branches`), which PostgreSQL 14 and older reject, so migrations stop there on an older server.
- `redis`: Redis 7 in-memory queue & cache.
- `migrate`: Automatic schema migration service; API and Worker wait for it to complete.
- `api`: Control plane REST API server (`cmd/inroad`).
diff --git a/docs/src/content/docs/deploy/kubernetes-helm.md b/docs/src/content/docs/deploy/kubernetes-helm.md
index 0bd65bb8..2cafb083 100644
--- a/docs/src/content/docs/deploy/kubernetes-helm.md
+++ b/docs/src/content/docs/deploy/kubernetes-helm.md
@@ -19,6 +19,10 @@ helm upgrade --install inroad ./deploy/helm/inroad \
--set secrets.masterKey="$(openssl rand -base64 32)"
```
+## Database requirement
+
+If you point the chart at an existing or managed Postgres rather than the one it deploys, check the version first. PostgreSQL **15 or newer is required**; 16 is what every bundled deployment runs and what CI tests against. The schema uses column-list `ON DELETE SET NULL (column)` foreign keys (the conditional-branching migration, `20260923110214_sequence_step_branches`), which PostgreSQL 14 and older reject, so migrations stop there on an older server.
+
## Chart Components
- **`deployment-api.yaml`:** API server pods with HTTP liveness/readiness probes.
diff --git a/docs/src/content/docs/security.md b/docs/src/content/docs/security.md
index 3f3f5e3d..e1ade168 100644
--- a/docs/src/content/docs/security.md
+++ b/docs/src/content/docs/security.md
@@ -2220,20 +2220,69 @@ write history that never happened.
`coreapi` (the worker holds no database access of its own). A malformed
or out-of-range value stops the process at startup.
+## Conditional branching (sequence step routing)
+84. **A branch predicate reads the stored HUMAN verdict, and nothing else.**
+ `opened`/`clicked` conditions (`FirstHumanTrackingEventAt`,
+ `internal/platform/db/queries/stepbranch.sql`) filter
+ `kind = ... AND NOT is_machine` on the cursor step's OWN deterministic send
+ id, the same definition `CountHumanOpens` reports, so a branch and the open
+ rate cannot disagree about a contact and a scanner's prefetch cannot fire an
+ "if opened" branch. Two cautions carry over from invariant 63: a `human`
+ verdict is only "not obviously a machine", so a branch whose wrong side is
+ expensive should prefer the negative path; and rows recorded before the
+ classification migration are all marked human, so a branch evaluated over old
+ history can over-fire. A reply condition counts inbound `inbox_messages` by
+ `created_at` (when WE ingested it), never the sender-controlled `Date`
+ header, and excludes automated labels unless one is named explicitly.
+
+ **Reply evidence is thread-scoped, not sender-verified.** A reply counts
+ because the inbox poller matched it to one of this campaign's sends by its
+ In-Reply-To/References headers and stored it on the enrollment's campaign +
+ contact thread — the same matching MarkReplied uses. Nothing proves the
+ CONTACT wrote it: anyone who knows a real Message-ID of a send can place a
+ message in that thread (the within-workspace spoofing gap listed under
+ Deferred). The blast radius is the same as that gap's and no wider: it can
+ route one enrollment of the workspace down a branch it could already be
+ routed down, and it cannot reach another tenant, suppress anything, or make
+ a stopping label's reply do anything but stop.
+
+ **Tenancy.** Every branch read and write is `workspace_id`-pinned, and every
+ step reference (source and both exits) is a composite FK on
+ `(id, campaign_id)` with `(campaign_id, workspace_id)` pinned to `campaigns`,
+ so a branch pointing into another campaign or tenant is unrepresentable even
+ for a write that skips the service. Graph writes (branch upsert/delete, step
+ delete, reorder) run under a per-campaign `FOR NO KEY UPDATE` lock that
+ matches zero rows for a foreign workspace (404) and re-validate acyclicity
+ inside the transaction. The send path has a runtime loop backstop that ends
+ the path rather than recovering-forward forever.
+
+ **Write preconditions do not widen tenancy.** A branch PUT/DELETE may carry
+ `expected_updated_at` (optimistic concurrency, so two editors cannot
+ silently overwrite each other). It is enforced in the write's own `WHERE`
+ clause (`InsertBranchIfAbsent`, `UpdateBranchIfUnchanged`,
+ `DeleteBranchIfUnchanged`) under the same graph lock, and the lock still
+ runs first: a foreign workspace gets the same 404 whatever it expects, and
+ never reaches the 409. The 409's `current` branch is read with the same
+ `(step_id, campaign_id, workspace_id)` pin as every other branch read, so it
+ can only ever show the caller its own workspace's row. The token is
+ `updated_at`, advanced by a trigger on every insert and update (including
+ the `ON DELETE SET NULL` of an exit) to a strictly later value than the
+ row's previous one, so a live row's token cannot repeat and let a stale
+ write through. (A branch deleted and re-created starts from the database
+ clock; only a clock stepping back to the exact microsecond of the deleted
+ row's token could collide.) It is a lost-update guard between
+ cooperating editors, not an access control: a client that omits it gets
+ last-writer-wins, exactly as before.
+
+ **A branch never overrides reply-label automation.** A reply whose label
+ stops the enrollment still stops it; compliance dispatch (invariants 20, 45)
+ is untouched. A branch only routes enrollments the labels leave active, and
+ the save path refuses a reply branch that names a stopping label
+ (`reply_label_stops_sequence`), since it could never fire. A paused, draft
+ or done campaign's enrollments are not routed at all: the not-running gate
+ runs BEFORE routing, so a hold never finishes or parks an enrollment.
+
## Deferred (documented, not yet built)
-- **Conditional branching on a sequence step must gate on HUMAN events only**
- (invariant 63). This is written down BEFORE the feature exists because getting
- it wrong is silent: a scanner's prefetch would fire an "if opened" branch and
- send the contact the wrong follow-up, with nothing in the UI to show that a bot
- rewrote a real sequence. A branch predicate must read the stored verdict —
- `... AND kind = 'open' AND NOT is_machine`, the same filter `CountHumanOpens`
- uses — and must never re-derive its own definition of an open, or the branch
- and the reported open rate will disagree about the same contact. Two further
- cautions: a `human` verdict is "not obviously a machine", so a branch whose
- wrong side is expensive or irreversible should prefer the negative path; and
- because the verdict is computed once at write time, rows recorded before that
- migration are all marked human and a branch reading old history will
- over-fire.
- Datacenter/cloud IP ranges as a refreshed table (AWS/GCP/Azure publish
machine-readable lists; Apple's MPP relay egress likewise). `botfilter`'s
compiled-in range list covers only what is knowable from the address itself,
diff --git a/internal/app/enrollment/service.go b/internal/app/enrollment/service.go
index 63b5783d..481eed5f 100644
--- a/internal/app/enrollment/service.go
+++ b/internal/app/enrollment/service.go
@@ -50,6 +50,20 @@ func (s *Service) MarkStepStopped(ctx context.Context, ws, id uuid.UUID, reason
return s.store.Stop(ctx, ws, id, reason)
}
+// FinishRoute completes an enrollment whose branch routed it to the end of its
+// path without another send. Distinct from the lastStep completion in
+// MarkStepSent, which records the final send; nothing was sent here.
+func (s *Service) FinishRoute(ctx context.Context, ws, id uuid.UUID) error {
+ return s.store.Finish(ctx, ws, id)
+}
+
+// AwaitCondition parks an active enrollment until recheckAt because its next
+// move depends on a branch condition that has not been decided, or on a routed
+// step that is not yet due.
+func (s *Service) AwaitCondition(ctx context.Context, ws, id uuid.UUID, recheckAt time.Time) error {
+ return s.store.AwaitCondition(ctx, ws, id, recheckAt)
+}
+
// Reschedule re-stamps an active enrollment's next due time (launch stagger).
func (s *Service) Reschedule(ctx context.Context, ws, id uuid.UUID, nextDueAt time.Time) error {
return s.store.SetDue(ctx, ws, id, nextDueAt)
diff --git a/internal/app/enrollment/service_test.go b/internal/app/enrollment/service_test.go
index f694a406..826add4a 100644
--- a/internal/app/enrollment/service_test.go
+++ b/internal/app/enrollment/service_test.go
@@ -17,6 +17,8 @@ type fakeStore struct {
completed bool
stoppedReason StopReason
threadRoot string
+ finished bool
+ awaitAt time.Time
}
func (f *fakeStore) Enroll(context.Context, uuid.UUID, uuid.UUID) ([]uuid.UUID, error) {
@@ -38,6 +40,14 @@ func (f *fakeStore) Stop(_ context.Context, _, _ uuid.UUID, r StopReason) error
return nil
}
func (f *fakeStore) SetDue(context.Context, uuid.UUID, uuid.UUID, time.Time) error { return nil }
+func (f *fakeStore) Finish(context.Context, uuid.UUID, uuid.UUID) error {
+ f.finished = true
+ return nil
+}
+func (f *fakeStore) AwaitCondition(_ context.Context, _, _ uuid.UUID, at time.Time) error {
+ f.awaitAt = at
+ return nil
+}
func (f *fakeStore) SetThreadRoot(_ context.Context, _, _ uuid.UUID, mid string) error {
f.threadRoot = mid
return nil
@@ -104,3 +114,26 @@ func TestMarkStepStoppedPassesReason(t *testing.T) {
t.Fatalf("want suppressed, got %s", f.stoppedReason)
}
}
+
+// A routed end completes WITHOUT recording a send: FinishRoute must not go
+// through Complete (which stamps current_step and last_sent_at).
+func TestFinishRouteCompletesWithoutASend(t *testing.T) {
+ f := &fakeStore{}
+ if err := NewService(f).FinishRoute(context.Background(), uuid.New(), uuid.New()); err != nil {
+ t.Fatal(err)
+ }
+ if !f.finished || f.completed {
+ t.Fatalf("finished=%v completed=%v, want the no-send finish only", f.finished, f.completed)
+ }
+}
+
+func TestAwaitConditionStampsRecheck(t *testing.T) {
+ f := &fakeStore{}
+ at := time.Now().Add(time.Hour)
+ if err := NewService(f).AwaitCondition(context.Background(), uuid.New(), uuid.New(), at); err != nil {
+ t.Fatal(err)
+ }
+ if !f.awaitAt.Equal(at) {
+ t.Fatalf("recheck = %v, want %v", f.awaitAt, at)
+ }
+}
diff --git a/internal/app/enrollment/store.go b/internal/app/enrollment/store.go
index ad2abaf3..693fff43 100644
--- a/internal/app/enrollment/store.go
+++ b/internal/app/enrollment/store.go
@@ -33,6 +33,14 @@ type Store interface {
SetDue(ctx context.Context, ws, id uuid.UUID, nextDueAt time.Time) error
// SetThreadRoot stores step 1's Message-ID once (while still empty).
SetThreadRoot(ctx context.Context, ws, id uuid.UUID, messageID string) error
+ // Finish completes an active enrollment WITHOUT a send (a branch routed it
+ // to the end of its path): current_step and last_sent_at are left alone.
+ // Guarded on status='active' like Complete.
+ Finish(ctx context.Context, ws, id uuid.UUID) error
+ // AwaitCondition re-stamps next_due_at for an active enrollment whose next
+ // move waits on a branch condition, and records that the wait is a
+ // condition's (awaiting_condition_step = current_step).
+ AwaitCondition(ctx context.Context, ws, id uuid.UUID, recheckAt time.Time) error
CountByStatus(ctx context.Context, ws, campaignID uuid.UUID) (map[string]int64, error)
}
@@ -72,6 +80,14 @@ func (s *PgStore) SetDue(ctx context.Context, ws, id uuid.UUID, nextDueAt time.T
func (s *PgStore) SetThreadRoot(ctx context.Context, ws, id uuid.UUID, messageID string) error {
return s.q.SetThreadRoot(ctx, gen.SetThreadRootParams{ID: id, WorkspaceID: ws, ThreadRootID: messageID})
}
+func (s *PgStore) Finish(ctx context.Context, ws, id uuid.UUID) error {
+ return s.q.FinishEnrollment(ctx, gen.FinishEnrollmentParams{ID: id, WorkspaceID: ws})
+}
+func (s *PgStore) AwaitCondition(ctx context.Context, ws, id uuid.UUID, recheckAt time.Time) error {
+ return s.q.AwaitEnrollmentCondition(ctx, gen.AwaitEnrollmentConditionParams{
+ ID: id, WorkspaceID: ws, NextDueAt: tsz(recheckAt),
+ })
+}
func (s *PgStore) CountByStatus(ctx context.Context, ws, campaignID uuid.UUID) (map[string]int64, error) {
rows, err := s.q.CountEnrollmentsByStatus(ctx, gen.CountEnrollmentsByStatusParams{CampaignID: campaignID, WorkspaceID: ws})
if err != nil {
diff --git a/internal/app/sequencestep/branch.go b/internal/app/sequencestep/branch.go
new file mode 100644
index 00000000..8e3633bb
--- /dev/null
+++ b/internal/app/sequencestep/branch.go
@@ -0,0 +1,266 @@
+package sequencestep
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "slices"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+
+ "github.com/inroad/inroad/internal/platform/db/gen"
+ "github.com/inroad/inroad/internal/platform/seqgraph"
+)
+
+// Graph is a campaign's steps (in step_order) and its routers — everything a
+// client needs to draw the sequence as a graph.
+type Graph struct {
+ Steps []gen.SequenceStep
+ Branches []gen.SequenceStepBranch
+}
+
+// requireCampaign resolves the campaign in the workspace. Only a genuine miss
+// is "campaign not found" (404); any other failure is a server error and is
+// returned as one, so a database outage is not reported to the client as a
+// campaign that does not exist.
+func (s *Service) requireCampaign(ctx context.Context, ws, campaignID uuid.UUID) error {
+ if _, err := s.checker.CampaignStatus(ctx, ws, campaignID); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return ErrCampaignNotFound
+ }
+ return fmt.Errorf("campaign status: %w", err)
+ }
+ return nil
+}
+
+// Graph returns the campaign's routing graph. Reading is allowed on any status.
+func (s *Service) Graph(ctx context.Context, ws, campaignID uuid.UUID) (Graph, error) {
+ if err := s.requireCampaign(ctx, ws, campaignID); err != nil {
+ return Graph{}, err
+ }
+ steps, err := s.store.List(ctx, ws, campaignID)
+ if err != nil {
+ return Graph{}, fmt.Errorf("list steps: %w", err)
+ }
+ branches, err := s.branches.ListBranches(ctx, ws, campaignID)
+ if err != nil {
+ return Graph{}, fmt.Errorf("list branches: %w", err)
+ }
+ return Graph{Steps: steps, Branches: branches}, nil
+}
+
+// SetBranch creates or replaces the router on one step.
+//
+// Allowed on a RUNNING campaign, like a content or variant edit and unlike a
+// step create/delete/reorder. The send path re-reads the graph on every advance
+// (see the mid-flight rule on inprocess.routeGraph), so an edit governs every
+// decision made after it commits and none made before — which is the same
+// live-reference contract a body edit has.
+//
+// Validation happens twice, on purpose. The shape, label and evidence checks
+// here give a precise error before any write; the graph check then runs AGAIN
+// inside the store's transaction, under the campaign's graph lock, against the
+// graph that is actually being committed — the only place a loop formed by two
+// concurrent edits can be caught.
+//
+// in.Expect, when set, is enforced by that same write (see
+// BranchStore.UpsertBranch). It is checked after validation, so a request that
+// is both stale and malformed is told it is malformed.
+func (s *Service) SetBranch(ctx context.Context, ws, campaignID uuid.UUID, in BranchInput) (gen.SequenceStepBranch, error) {
+ if err := s.requireCampaign(ctx, ws, campaignID); err != nil {
+ return gen.SequenceStepBranch{}, err
+ }
+ step, err := s.stepInCampaign(ctx, ws, campaignID, in.StepID)
+ if err != nil {
+ return gen.SequenceStepBranch{}, err
+ }
+ // The model reads an absent window as 0, which is exactly right for a real
+ // condition (0 is out of range) but would let an explicit "within_days": 0 on
+ // an 'always' branch through; refuse any window on 'always' here.
+ if in.Condition == string(seqgraph.Always) && in.WithinDays != nil {
+ return gen.SequenceStepBranch{}, &seqgraph.ShapeError{
+ Code: seqgraph.CodeInvalidWithinDays, Msg: "within_days is not allowed on an 'always' branch",
+ }
+ }
+ model := branchModel(in)
+ if err := model.ValidateShape(); err != nil {
+ return gen.SequenceStepBranch{}, err
+ }
+ if err := s.checkReplyLabel(ctx, ws, in.ReplyLabelKey); err != nil {
+ return gen.SequenceStepBranch{}, err
+ }
+ if err := s.checkTrackable(ctx, ws, campaignID, step, model.Condition.Signal()); err != nil {
+ return gen.SequenceStepBranch{}, err
+ }
+ // Refuse an exit to a step outside the campaign BEFORE writing: the
+ // composite FK would refuse it too, but as a constraint violation rather than
+ // an error that names the offending target.
+ steps, err := s.store.List(ctx, ws, campaignID)
+ if err != nil {
+ return gen.SequenceStepBranch{}, fmt.Errorf("list steps: %w", err)
+ }
+ for _, target := range []*uuid.UUID{in.YesStepID, in.NoStepID} {
+ if target != nil && !containsStep(steps, *target) {
+ return gen.SequenceStepBranch{}, &seqgraph.TargetError{StepID: in.StepID, Target: *target}
+ }
+ }
+ in.CampaignID = campaignID
+ return s.branches.UpsertBranch(ctx, ws, in, checkGraph)
+}
+
+// checkReplyLabel refuses a label that could never fire a branch.
+//
+// Reply labels decide what a reply DOES to an enrollment, and a branch never
+// overrides that (docs/security.md invariant 84). A label that stops the
+// enrollment — the default for every builtin human label — ends the sequence
+// the moment such a reply arrives, before any branch is consulted, so a branch
+// naming it would silently never route anyone. Refusing it at save time is the
+// honest answer; the operator can clear stops_enrollment on the label (or pick
+// one that does not stop) if routing on it is what they want.
+//
+// Checked at save time only. A label edited to stop AFTER a branch names it
+// does not break anything: the reply stops the sequence, which is what the label
+// now says, and the branch simply never fires.
+func (s *Service) checkReplyLabel(ctx context.Context, ws uuid.UUID, key *string) error {
+ if key == nil {
+ return nil
+ }
+ stops, found, err := s.branches.ReplyLabelStops(ctx, ws, *key)
+ if err != nil {
+ return fmt.Errorf("check reply label: %w", err)
+ }
+ if !found {
+ return &seqgraph.ShapeError{
+ Code: seqgraph.CodeLabelNotAllowed,
+ Msg: fmt.Sprintf("reply label %q does not exist in this workspace", *key),
+ }
+ }
+ if stops {
+ return &seqgraph.ShapeError{
+ Code: seqgraph.CodeLabelStopsSequence,
+ Msg: fmt.Sprintf("reply label %q stops the sequence, so a reply with it can never be routed; "+
+ "use a label that does not stop the enrollment", *key),
+ }
+ }
+ return nil
+}
+
+// checkTrackable refuses an open/click condition that has no evidence to read.
+//
+// Opens and clicks exist only when the campaign has tracking on AND the step
+// (every copy of it — the base and each A/B variant) has an HTML body for the
+// pixel and the rewritten links to live in. Without both, not one open or click
+// will ever be recorded: "opened" would route every contact NO at the deadline
+// and "not_opened" every contact YES, which looks like a working branch and is
+// really a fixed route with a delay.
+//
+// Refused at save time rather than degraded, because the refusal is the only
+// point where the operator can see why. Turning tracking off or editing a step
+// to text-only AFTER the branch exists is not blocked — those edits belong to
+// other endpoints (and, for tracking, another domain), and blocking a tracking
+// toggle on account of a branch would make a privacy setting hostage to
+// sequence design. Such a branch then takes the no-evidence route at its
+// deadline, which is the documented behaviour.
+func (s *Service) checkTrackable(ctx context.Context, ws, campaignID uuid.UUID, step gen.SequenceStep, signal seqgraph.Signal) error {
+ if signal != seqgraph.SignalOpen && signal != seqgraph.SignalClick {
+ return nil
+ }
+ enabled, err := s.branches.TrackingEnabled(ctx, ws, campaignID)
+ if err != nil {
+ return fmt.Errorf("check tracking: %w", err)
+ }
+ if !enabled {
+ return &seqgraph.ShapeError{
+ Code: seqgraph.CodeTrackingRequired,
+ Msg: "open and click conditions need tracking turned on for this campaign",
+ }
+ }
+ variants, err := s.variants.ListForStep(ctx, ws, step.ID)
+ if err != nil {
+ return fmt.Errorf("list step variants: %w", err)
+ }
+ textOnly := step.BodyHtml == "" ||
+ slices.ContainsFunc(variants, func(v Variant) bool { return v.BodyHTML == "" })
+ if textOnly {
+ return &seqgraph.ShapeError{
+ Code: seqgraph.CodeTrackingRequired,
+ Msg: "open and click conditions need an HTML body on this step and every one of its variants",
+ }
+ }
+ return nil
+}
+
+// DeleteBranch removes the router on one step, returning it to linear
+// fall-through. Idempotent without a precondition. Allowed live, for the reason
+// SetBranch is; refused with a CycleError when the restored fall-through would
+// close a loop, and with a *BranchChangedError when expect no longer holds.
+func (s *Service) DeleteBranch(ctx context.Context, ws, campaignID, stepID uuid.UUID, expect BranchPrecondition) error {
+ if err := s.requireCampaign(ctx, ws, campaignID); err != nil {
+ return err
+ }
+ if _, err := s.stepInCampaign(ctx, ws, campaignID, stepID); err != nil {
+ return err
+ }
+ return s.branches.DeleteBranch(ctx, ws, campaignID, stepID, expect, checkGraph)
+}
+
+// checkGraph is the one GraphCheck every graph-changing write commits under.
+func checkGraph(steps []gen.SequenceStep, branches []gen.SequenceStepBranch) error {
+ return BuildGraph(steps, branches).Validate()
+}
+
+// BuildGraph converts the persistence rows into the routing model. Exported so
+// the handler can compute each step's fall-through with the SAME rule the send
+// path and the validator use, rather than re-deriving "next by step_order".
+func BuildGraph(steps []gen.SequenceStep, branches []gen.SequenceStepBranch) seqgraph.Graph {
+ nodes := make([]seqgraph.Step, len(steps))
+ for i, st := range steps {
+ nodes[i] = seqgraph.Step{ID: st.ID, Order: st.StepOrder, DelaySeconds: st.DelaySeconds}
+ }
+ routers := make([]seqgraph.Branch, len(branches))
+ for i, b := range branches {
+ routers[i] = BranchFromRow(b)
+ }
+ return seqgraph.New(nodes, routers)
+}
+
+// BranchFromRow converts one stored router into the routing model.
+func BranchFromRow(b gen.SequenceStepBranch) seqgraph.Branch {
+ out := seqgraph.Branch{StepID: b.StepID, Condition: seqgraph.Condition(b.Condition)}
+ if b.WithinDays != nil {
+ out.WithinDays = int(*b.WithinDays)
+ }
+ if b.ReplyLabelKey != nil {
+ out.ReplyLabelKey = *b.ReplyLabelKey
+ }
+ if b.YesStepID.Valid {
+ out.Yes = b.YesStepID.Bytes
+ }
+ if b.NoStepID.Valid {
+ out.No = b.NoStepID.Bytes
+ }
+ return out
+}
+
+// branchModel converts a write request into the routing model for validation.
+func branchModel(in BranchInput) seqgraph.Branch {
+ out := seqgraph.Branch{StepID: in.StepID, Condition: seqgraph.Condition(in.Condition)}
+ if in.WithinDays != nil {
+ out.WithinDays = int(*in.WithinDays)
+ }
+ if in.ReplyLabelKey != nil {
+ out.ReplyLabelKey = *in.ReplyLabelKey
+ }
+ if in.YesStepID != nil {
+ out.Yes = *in.YesStepID
+ }
+ if in.NoStepID != nil {
+ out.No = *in.NoStepID
+ }
+ return out
+}
+
+func containsStep(steps []gen.SequenceStep, id uuid.UUID) bool {
+ return slices.ContainsFunc(steps, func(st gen.SequenceStep) bool { return st.ID == id })
+}
diff --git a/internal/app/sequencestep/branch_integration_test.go b/internal/app/sequencestep/branch_integration_test.go
new file mode 100644
index 00000000..cf347bf0
--- /dev/null
+++ b/internal/app/sequencestep/branch_integration_test.go
@@ -0,0 +1,339 @@
+//go:build integration
+
+package sequencestep
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/inroad/inroad/internal/platform/db"
+ "github.com/inroad/inroad/internal/platform/db/dbtest"
+ "github.com/inroad/inroad/internal/platform/db/gen"
+ "github.com/inroad/inroad/internal/platform/seqgraph"
+)
+
+// sqlChecker is the CampaignChecker the composition root builds over the
+// campaign store, reduced to the one workspace-pinned read it needs.
+type sqlChecker struct{ pool *pgxpool.Pool }
+
+func (c sqlChecker) CampaignStatus(ctx context.Context, ws, campaignID uuid.UUID) (string, error) {
+ var status string
+ err := c.pool.QueryRow(ctx, `SELECT status FROM campaigns WHERE id = $1 AND workspace_id = $2`, campaignID, ws).Scan(&status)
+ return status, err
+}
+
+type branchIT struct {
+ pool *pgxpool.Pool
+ q *gen.Queries
+ svc *Service
+ ws uuid.UUID
+ campaign uuid.UUID
+ steps []uuid.UUID
+}
+
+func newBranchIT(t *testing.T, label string) (branchIT, func()) {
+ t.Helper()
+ ctx := context.Background()
+ if err := db.Migrate(dbtest.DSN(t)); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+ pool, err := db.Connect(ctx, dbtest.DSN(t))
+ if err != nil {
+ t.Fatalf("connect: %v", err)
+ }
+ q := gen.New(pool)
+ ws, campaign, ids := seedThreeSteps(t, ctx, q, label)
+ // HTML bodies, so open/click branches have somewhere to record evidence
+ // (tracking_enabled defaults to true).
+ if _, err := pool.Exec(ctx, `UPDATE sequence_steps SET body_html = '
b
' WHERE campaign_id = $1`, campaign); err != nil {
+ t.Fatalf("html bodies: %v", err)
+ }
+ svc := NewService(NewPgStore(pool), sqlChecker{pool: pool}, NewPgVariantStore(q), NewPgBranchStore(pool))
+ return branchIT{pool: pool, q: q, svc: svc, ws: ws, campaign: campaign, steps: ids}, pool.Close
+}
+
+func (b branchIT) addStep(t *testing.T, order int32) uuid.UUID {
+ t.Helper()
+ st, err := b.q.CreateStep(context.Background(), gen.CreateStepParams{
+ WorkspaceID: b.ws, CampaignID: b.campaign, StepOrder: order, Subject: "s", BodyText: "b",
+ })
+ if err != nil {
+ t.Fatalf("step %d: %v", order, err)
+ }
+ return st.ID
+}
+
+func (b branchIT) always(t *testing.T, from uuid.UUID, to *uuid.UUID) {
+ t.Helper()
+ if _, err := b.svc.SetBranch(context.Background(), b.ws, b.campaign, BranchInput{StepID: from, Condition: "always", YesStepID: to}); err != nil {
+ t.Fatalf("always %v -> %v: %v", from, to, err)
+ }
+}
+
+func TestBranchSaveAndReadBack(t *testing.T) {
+ b, done := newBranchIT(t, "Branch save")
+ defer done()
+ ctx := context.Background()
+ three := int32(3)
+ // A custom label that does NOT stop the enrollment: the only kind a branch
+ // can route on (the builtin human labels all stop the sequence).
+ label := "soft_yes"
+ if _, err := b.pool.Exec(ctx, `
+ INSERT INTO reply_labels (workspace_id, key, label, color, position, stops_enrollment)
+ VALUES ($1, $2, 'Soft yes', '#123456', 9, false)`, b.ws, label); err != nil {
+ t.Fatalf("custom label: %v", err)
+ }
+ got, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{
+ StepID: b.steps[0], Condition: "replied", WithinDays: &three, ReplyLabelKey: &label,
+ YesStepID: &b.steps[2], NoStepID: &b.steps[1],
+ })
+ if err != nil {
+ t.Fatalf("SetBranch: %v", err)
+ }
+ if got.WorkspaceID != b.ws || got.CampaignID != b.campaign || got.ReplyLabelKey == nil || *got.ReplyLabelKey != label {
+ t.Fatalf("stored = %+v", got)
+ }
+
+ // Replace in place: one row per step.
+ if _, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always"}); err != nil {
+ t.Fatalf("replace: %v", err)
+ }
+ g, err := b.svc.Graph(ctx, b.ws, b.campaign)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(g.Branches) != 1 || g.Branches[0].Condition != "always" || g.Branches[0].WithinDays != nil || g.Branches[0].YesStepID.Valid {
+ t.Fatalf("graph branches = %+v", g.Branches)
+ }
+
+ if err := b.svc.DeleteBranch(ctx, b.ws, b.campaign, b.steps[0], BranchPrecondition{}); err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ if g, _ := b.svc.Graph(ctx, b.ws, b.campaign); len(g.Branches) != 0 {
+ t.Fatalf("branch survived delete: %+v", g.Branches)
+ }
+}
+
+// Against the real seeded taxonomy: every builtin human label stops the
+// enrollment, so none of them can be named by a reply branch, while the
+// automated ones (which leave the enrollment running) can.
+func TestBranchRefusesSeededStoppingLabels(t *testing.T) {
+ b, done := newBranchIT(t, "Branch seeded labels")
+ defer done()
+ ctx := context.Background()
+ two := int32(2)
+ for _, key := range []string{"positive", "negative", "neutral", "unknown", "unsubscribe"} {
+ k := key
+ _, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{
+ StepID: b.steps[0], Condition: "replied", WithinDays: &two, ReplyLabelKey: &k, YesStepID: &b.steps[2],
+ })
+ if seqgraph.CodeOf(err) != seqgraph.CodeLabelStopsSequence {
+ t.Errorf("%s: code %q (%v), want %q", key, seqgraph.CodeOf(err), err, seqgraph.CodeLabelStopsSequence)
+ }
+ }
+ ooo := "out_of_office"
+ if _, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{
+ StepID: b.steps[0], Condition: "replied", WithinDays: &two, ReplyLabelKey: &ooo, YesStepID: &b.steps[2],
+ }); err != nil {
+ t.Fatalf("an automated (non-stopping) label is routable: %v", err)
+ }
+}
+
+// Tracking off on the campaign refuses an open branch against the real column.
+func TestBranchOpenRefusedWhenTrackingOff(t *testing.T) {
+ b, done := newBranchIT(t, "Branch tracking off")
+ defer done()
+ ctx := context.Background()
+ if _, err := b.pool.Exec(ctx, `UPDATE campaigns SET tracking_enabled = false WHERE id = $1`, b.campaign); err != nil {
+ t.Fatal(err)
+ }
+ one := int32(1)
+ _, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "opened", WithinDays: &one})
+ if seqgraph.CodeOf(err) != seqgraph.CodeTrackingRequired {
+ t.Fatalf("code %q (%v), want %q", seqgraph.CodeOf(err), err, seqgraph.CodeTrackingRequired)
+ }
+}
+
+func TestBranchCycleRefusedAndNothingWritten(t *testing.T) {
+ b, done := newBranchIT(t, "Branch cycle")
+ defer done()
+ _, err := b.svc.SetBranch(context.Background(), b.ws, b.campaign, BranchInput{
+ StepID: b.steps[2], Condition: "always", YesStepID: &b.steps[0],
+ })
+ var cyc *seqgraph.CycleError
+ if !errors.As(err, &cyc) {
+ t.Fatalf("want CycleError, got %v", err)
+ }
+ if g, _ := b.svc.Graph(context.Background(), b.ws, b.campaign); len(g.Branches) != 0 {
+ t.Fatalf("a refused edit was committed: %+v", g.Branches)
+ }
+}
+
+// A target in ANOTHER campaign of the same workspace is refused by the service
+// with a named error, and by the composite FK if the service is bypassed.
+func TestBranchTargetMustBeInSameCampaign(t *testing.T) {
+ b, done := newBranchIT(t, "Branch target owner")
+ defer done()
+ ctx := context.Background()
+ _, _, others := seedThreeSteps(t, ctx, b.q, "Branch target other")
+
+ _, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always", YesStepID: &others[0]})
+ var te *seqgraph.TargetError
+ if !errors.As(err, &te) {
+ t.Fatalf("service: want TargetError, got %v", err)
+ }
+
+ store := NewPgBranchStore(b.pool)
+ _, err = store.UpsertBranch(ctx, b.ws, BranchInput{
+ CampaignID: b.campaign, StepID: b.steps[0], Condition: "always", YesStepID: &others[0],
+ }, checkGraph)
+ if !errors.Is(err, ErrTargetGone) {
+ t.Fatalf("store backstop: want ErrTargetGone (composite FK), got %v", err)
+ }
+}
+
+// Every graph write takes the campaign lock on (campaign, workspace) first, so
+// a foreign workspace writes nothing — even through the store directly.
+func TestBranchWritesAreWorkspacePinned(t *testing.T) {
+ b, done := newBranchIT(t, "Branch tenant owner")
+ defer done()
+ ctx := context.Background()
+ intruder, err := b.q.CreateWorkspace(ctx, "Branch tenant intruder "+uuid.NewString())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := b.svc.SetBranch(ctx, intruder.ID, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always"}); !errors.Is(err, ErrCampaignNotFound) {
+ t.Fatalf("service: want ErrCampaignNotFound, got %v", err)
+ }
+ store := NewPgBranchStore(b.pool)
+ if _, err := store.UpsertBranch(ctx, intruder.ID, BranchInput{CampaignID: b.campaign, StepID: b.steps[0], Condition: "always"}, checkGraph); !errors.Is(err, ErrCampaignNotFound) {
+ t.Fatalf("store: want ErrCampaignNotFound, got %v", err)
+ }
+ if err := store.DeleteBranch(ctx, intruder.ID, b.campaign, b.steps[0], BranchPrecondition{}, checkGraph); !errors.Is(err, ErrCampaignNotFound) {
+ t.Fatalf("store delete: want ErrCampaignNotFound, got %v", err)
+ }
+ if rows, _ := b.q.ListBranchesByCampaign(ctx, gen.ListBranchesByCampaignParams{CampaignID: b.campaign, WorkspaceID: intruder.ID}); len(rows) != 0 {
+ t.Fatalf("foreign workspace sees %d branches", len(rows))
+ }
+ if g, _ := b.svc.Graph(ctx, b.ws, b.campaign); len(g.Branches) != 0 {
+ t.Fatalf("intruder wrote into the owner's graph: %+v", g.Branches)
+ }
+}
+
+// Deleting a target step turns the exits that pointed at it into ends (ON
+// DELETE SET NULL on the target column only), and deleting a SOURCE step
+// removes its router.
+func TestStepDeleteNullsExitsAndDropsRouter(t *testing.T) {
+ b, done := newBranchIT(t, "Branch step delete")
+ defer done()
+ ctx := context.Background()
+ two := int32(2)
+ if _, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{
+ StepID: b.steps[0], Condition: "opened", WithinDays: &two, YesStepID: &b.steps[2], NoStepID: &b.steps[1],
+ }); err != nil {
+ t.Fatal(err)
+ }
+ b.always(t, b.steps[1], nil)
+
+ if err := b.svc.Delete(ctx, b.ws, b.campaign, b.steps[2]); err != nil {
+ t.Fatalf("delete target: %v", err)
+ }
+ g, err := b.svc.Graph(ctx, b.ws, b.campaign)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, br := range g.Branches {
+ if br.StepID == b.steps[0] {
+ if br.YesStepID.Valid || !br.NoStepID.Valid || br.CampaignID != b.campaign {
+ t.Fatalf("after target delete = %+v, want yes nulled, no kept", br)
+ }
+ }
+ }
+
+ if err := b.svc.Delete(ctx, b.ws, b.campaign, b.steps[1]); err != nil {
+ t.Fatalf("delete source: %v", err)
+ }
+ g, _ = b.svc.Graph(ctx, b.ws, b.campaign)
+ if len(g.Branches) != 1 || g.Branches[0].StepID != b.steps[0] || g.Branches[0].NoStepID.Valid {
+ t.Fatalf("after source delete = %+v", g.Branches)
+ }
+}
+
+// A delete re-links the fall-through around the removed step. Here A -> B (by
+// order), B -> D, C -> A, D ends: acyclic. Deleting B makes A fall through to C,
+// and C -> A closes a loop, so the delete is refused and rolled back.
+func TestStepDeleteRefusedWhenFallThroughClosesLoop(t *testing.T) {
+ b, done := newBranchIT(t, "Branch delete cycle")
+ defer done()
+ ctx := context.Background()
+ a, bb, c := b.steps[0], b.steps[1], b.steps[2]
+ d := b.addStep(t, 4)
+ b.always(t, bb, &d)
+ b.always(t, c, &a)
+
+ err := b.svc.Delete(ctx, b.ws, b.campaign, bb)
+ if seqgraph.CodeOf(err) != seqgraph.CodeCycle {
+ t.Fatalf("want a cycle refusal, got %v", err)
+ }
+ if _, err := b.q.GetStep(ctx, gen.GetStepParams{ID: bb, WorkspaceID: b.ws}); err != nil {
+ t.Fatalf("the refused delete must be rolled back: %v", err)
+ }
+}
+
+// Reordering moves every fall-through edge. A -> C explicitly with B and C
+// falling through is acyclic; the order [C, B, A] makes C -> B -> A by order,
+// and A -> C closes the loop.
+func TestReorderRefusedWhenFallThroughClosesLoop(t *testing.T) {
+ b, done := newBranchIT(t, "Branch reorder cycle")
+ defer done()
+ ctx := context.Background()
+ a, bb, c := b.steps[0], b.steps[1], b.steps[2]
+ b.always(t, a, &c)
+
+ _, err := b.svc.Reorder(ctx, b.ws, b.campaign, []uuid.UUID{c, bb, a})
+ if seqgraph.CodeOf(err) != seqgraph.CodeCycle {
+ t.Fatalf("want a cycle refusal, got %v", err)
+ }
+ after, err := b.q.ListStepsByCampaign(ctx, gen.ListStepsByCampaignParams{CampaignID: b.campaign, WorkspaceID: b.ws})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := orderedIDs(after); !equalIDs(got, b.steps) {
+ t.Fatalf("refused reorder was committed: %v", got)
+ }
+}
+
+// The composite FKs make a cross-campaign reference unrepresentable even for a
+// raw write that skips every Go check.
+func TestBranchSchemaRefusesForeignReferences(t *testing.T) {
+ b, done := newBranchIT(t, "Branch schema owner")
+ defer done()
+ ctx := context.Background()
+ otherWS, otherCampaign, others := seedThreeSteps(t, ctx, b.q, "Branch schema other")
+
+ cases := map[string]struct {
+ ws, campaign, step uuid.UUID
+ yes *uuid.UUID
+ }{
+ "source step of another campaign": {b.ws, b.campaign, others[0], nil},
+ "campaign of another workspace": {b.ws, otherCampaign, others[0], nil},
+ "target in another campaign": {b.ws, b.campaign, b.steps[0], &others[1]},
+ "workspace mismatching campaign": {otherWS, b.campaign, b.steps[0], nil},
+ }
+ for name, tc := range cases {
+ t.Run(name, func(t *testing.T) {
+ _, err := b.q.UpsertBranch(ctx, gen.UpsertBranchParams{
+ StepID: tc.step, WorkspaceID: tc.ws, CampaignID: tc.campaign, Condition: "always",
+ YesStepID: nullUUID(tc.yes),
+ })
+ if err == nil {
+ t.Fatal("the schema accepted a foreign reference")
+ }
+ })
+ }
+}
diff --git a/internal/app/sequencestep/branch_test.go b/internal/app/sequencestep/branch_test.go
new file mode 100644
index 00000000..5feb6fbc
--- /dev/null
+++ b/internal/app/sequencestep/branch_test.go
@@ -0,0 +1,441 @@
+package sequencestep
+
+import (
+ "context"
+ "errors"
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgtype"
+
+ "github.com/inroad/inroad/internal/platform/db/gen"
+ "github.com/inroad/inroad/internal/platform/seqgraph"
+)
+
+// fakeBranchStore keeps routers in memory and, like the real store, runs the
+// GraphCheck against the graph AS IT WOULD BE COMMITTED, rolling back on error.
+// steps is the campaign's step list the check sees.
+type fakeBranchStore struct {
+ steps []gen.SequenceStep
+ branches map[uuid.UUID]gen.SequenceStepBranch
+ // labels maps a label key to whether it stops the enrollment; a missing key
+ // is a label that does not exist.
+ labels map[string]bool
+ // trackingOff models a campaign with tracking disabled.
+ trackingOff bool
+ upserts int
+ // expects records every precondition the store was handed, in call order.
+ expects []BranchPrecondition
+}
+
+func (f *fakeBranchStore) ListBranches(context.Context, uuid.UUID, uuid.UUID) ([]gen.SequenceStepBranch, error) {
+ return f.list(), nil
+}
+
+func (f *fakeBranchStore) ReplyLabelStops(_ context.Context, _ uuid.UUID, key string) (stops, found bool, err error) {
+ stops, found = f.labels[key]
+ return stops, found, nil
+}
+
+func (f *fakeBranchStore) TrackingEnabled(context.Context, uuid.UUID, uuid.UUID) (bool, error) {
+ return !f.trackingOff, nil
+}
+
+func (f *fakeBranchStore) UpsertBranch(_ context.Context, ws uuid.UUID, in BranchInput, check GraphCheck) (gen.SequenceStepBranch, error) {
+ f.expects = append(f.expects, in.Expect)
+ row := gen.SequenceStepBranch{
+ StepID: in.StepID, WorkspaceID: ws, CampaignID: in.CampaignID, Condition: in.Condition,
+ WithinDays: in.WithinDays, ReplyLabelKey: in.ReplyLabelKey,
+ YesStepID: nullUUID(in.YesStepID), NoStepID: nullUUID(in.NoStepID),
+ }
+ prev, had := f.branches[in.StepID]
+ if f.branches == nil {
+ f.branches = map[uuid.UUID]gen.SequenceStepBranch{}
+ }
+ f.branches[in.StepID] = row
+ if err := check(f.steps, f.list()); err != nil {
+ if had {
+ f.branches[in.StepID] = prev
+ } else {
+ delete(f.branches, in.StepID)
+ }
+ return gen.SequenceStepBranch{}, err
+ }
+ f.upserts++
+ return row, nil
+}
+
+func (f *fakeBranchStore) DeleteBranch(_ context.Context, _, _, stepID uuid.UUID, expect BranchPrecondition, check GraphCheck) error {
+ f.expects = append(f.expects, expect)
+ prev, had := f.branches[stepID]
+ delete(f.branches, stepID)
+ if err := check(f.steps, f.list()); err != nil {
+ if had {
+ f.branches[stepID] = prev
+ }
+ return err
+ }
+ return nil
+}
+
+func (f *fakeBranchStore) list() []gen.SequenceStepBranch {
+ out := make([]gen.SequenceStepBranch, 0, len(f.branches))
+ for _, b := range f.branches {
+ out = append(out, b)
+ }
+ return out
+}
+
+// branchFixture is a campaign with three linear steps and both fakes wired so
+// the service's own step lookups and the store's graph check see the same steps.
+type branchFixture struct {
+ svc *Service
+ branches *fakeBranchStore
+ variants *fakeVariantStore
+ steps *stepsByID
+ campaign uuid.UUID
+ ws uuid.UUID
+ step []uuid.UUID
+}
+
+func newBranchFixture(t *testing.T, status string) branchFixture {
+ t.Helper()
+ campaign := uuid.New()
+ var steps []gen.SequenceStep
+ var ids []uuid.UUID
+ for i := range 3 {
+ id := uuid.New()
+ ids = append(ids, id)
+ steps = append(steps, gen.SequenceStep{ID: id, CampaignID: campaign, StepOrder: int32(i + 1), BodyHtml: "
hi
"})
+ }
+ // "positive" is a builtin label (stops the enrollment); "soft_yes" is a
+ // custom one that does not.
+ bs := &fakeBranchStore{steps: steps, labels: map[string]bool{"positive": true, "soft_yes": false}}
+ store := &stepsByID{steps: steps}
+ vs := &fakeVariantStore{}
+ return branchFixture{
+ svc: NewService(store, fakeChecker{status: status}, vs, bs),
+ branches: bs, variants: vs, steps: store, campaign: campaign, ws: uuid.New(), step: ids,
+ }
+}
+
+// stepsByID is a Store whose Get and List answer from a fixed step set, so
+// assertStepInCampaign behaves as it does against the database.
+type stepsByID struct {
+ fakeStore
+ steps []gen.SequenceStep
+}
+
+func (s *stepsByID) Get(_ context.Context, _, id uuid.UUID) (gen.SequenceStep, error) {
+ for _, st := range s.steps {
+ if st.ID == id {
+ return st, nil
+ }
+ }
+ return gen.SequenceStep{}, errors.New("no rows")
+}
+
+func (s *stepsByID) List(context.Context, uuid.UUID, uuid.UUID) ([]gen.SequenceStep, error) {
+ return s.steps, nil
+}
+
+func ptr[T any](v T) *T { return &v }
+
+func TestSetBranchHappyPathOnRunningCampaign(t *testing.T) {
+ f := newBranchFixture(t, "running")
+ b, err := f.svc.SetBranch(context.Background(), f.ws, f.campaign, BranchInput{
+ StepID: f.step[0], Condition: "opened", WithinDays: ptr(int32(3)),
+ YesStepID: &f.step[2], NoStepID: &f.step[1],
+ })
+ if err != nil {
+ t.Fatalf("a branch edit is allowed live: %v", err)
+ }
+ if b.CampaignID != f.campaign || b.Condition != "opened" || !b.YesStepID.Valid || b.YesStepID.Bytes != f.step[2] {
+ t.Fatalf("stored branch = %+v", b)
+ }
+}
+
+func TestSetBranchRejectsMissingCampaign(t *testing.T) {
+ f := newBranchFixture(t, "draft")
+ svc := NewService(&fakeStore{}, fakeChecker{err: pgx.ErrNoRows}, &fakeVariantStore{}, f.branches)
+ _, err := svc.SetBranch(context.Background(), f.ws, f.campaign, BranchInput{StepID: f.step[0], Condition: "always"})
+ if !errors.Is(err, ErrCampaignNotFound) {
+ t.Fatalf("want ErrCampaignNotFound, got %v", err)
+ }
+}
+
+// Only a genuine miss is a 404. A database failure looking the campaign up is
+// a server error, and all three graph endpoints must say so rather than
+// telling the client the campaign does not exist.
+func TestGraphEndpointsDoNotReportADatabaseErrorAsNotFound(t *testing.T) {
+ f := newBranchFixture(t, "draft")
+ boom := errors.New("connection reset")
+ svc := NewService(f.steps, fakeChecker{err: boom}, &fakeVariantStore{}, f.branches)
+ ctx := context.Background()
+ _, gerr := svc.Graph(ctx, f.ws, f.campaign)
+ _, serr := svc.SetBranch(ctx, f.ws, f.campaign, BranchInput{StepID: f.step[0], Condition: "always"})
+ derr := svc.DeleteBranch(ctx, f.ws, f.campaign, f.step[0], BranchPrecondition{})
+ for name, err := range map[string]error{"Graph": gerr, "SetBranch": serr, "DeleteBranch": derr} {
+ if !errors.Is(err, boom) || errors.Is(err, ErrCampaignNotFound) {
+ t.Errorf("%s: got %v, want the wrapped database error", name, err)
+ }
+ }
+}
+
+// A reply condition may name a label only if replies with it leave the
+// enrollment running: a stopping label (every builtin human one) ends the
+// sequence before any branch is consulted, so the branch could never fire.
+func TestSetBranchReplyLabelMustNotStopTheSequence(t *testing.T) {
+ ctx := context.Background()
+ f := newBranchFixture(t, "running")
+ _, err := f.svc.SetBranch(ctx, f.ws, f.campaign, BranchInput{
+ StepID: f.step[0], Condition: "replied", WithinDays: ptr(int32(2)), ReplyLabelKey: ptr("positive"), YesStepID: &f.step[2],
+ })
+ if seqgraph.CodeOf(err) != seqgraph.CodeLabelStopsSequence {
+ t.Fatalf("stopping label: code %q (%v), want %q", seqgraph.CodeOf(err), err, seqgraph.CodeLabelStopsSequence)
+ }
+ if _, err := f.svc.SetBranch(ctx, f.ws, f.campaign, BranchInput{
+ StepID: f.step[0], Condition: "not_replied", WithinDays: ptr(int32(2)), ReplyLabelKey: ptr("soft_yes"), YesStepID: &f.step[2],
+ }); err != nil {
+ t.Fatalf("a non-stopping label is routable: %v", err)
+ }
+}
+
+// Open and click conditions need somewhere for an open or click to be recorded:
+// tracking on for the campaign, and an HTML body on the step and every variant.
+// Reply conditions need neither.
+func TestSetBranchOpenClickNeedTracking(t *testing.T) {
+ ctx := context.Background()
+ openIn := func(f branchFixture, cond string) BranchInput {
+ return BranchInput{StepID: f.step[0], Condition: cond, WithinDays: ptr(int32(1)), YesStepID: &f.step[1]}
+ }
+
+ f := newBranchFixture(t, "running")
+ f.branches.trackingOff = true
+ for _, cond := range []string{"opened", "clicked", "not_opened"} {
+ if _, err := f.svc.SetBranch(ctx, f.ws, f.campaign, openIn(f, cond)); seqgraph.CodeOf(err) != seqgraph.CodeTrackingRequired {
+ t.Errorf("%s with tracking off: %v", cond, err)
+ }
+ }
+ if _, err := f.svc.SetBranch(ctx, f.ws, f.campaign, openIn(f, "replied")); err != nil {
+ t.Errorf("replied does not need tracking: %v", err)
+ }
+
+ f = newBranchFixture(t, "running")
+ f.steps.steps[0].BodyHtml = ""
+ if _, err := f.svc.SetBranch(ctx, f.ws, f.campaign, openIn(f, "opened")); seqgraph.CodeOf(err) != seqgraph.CodeTrackingRequired {
+ t.Errorf("text-only step: %v", err)
+ }
+
+ f = newBranchFixture(t, "running")
+ f.variants.variants = []Variant{{ID: uuid.New(), StepID: f.step[0], BodyHTML: "
b
"}, {ID: uuid.New(), StepID: f.step[0]}}
+ if _, err := f.svc.SetBranch(ctx, f.ws, f.campaign, openIn(f, "clicked")); seqgraph.CodeOf(err) != seqgraph.CodeTrackingRequired {
+ t.Errorf("a text-only variant: %v", err)
+ }
+ if f.branches.upserts != 0 {
+ t.Fatal("a refused open/click branch must not be written")
+ }
+}
+
+// A step of a different campaign (or tenant — the workspace-pinned Get simply
+// does not find it) is not routable through this campaign's URL.
+func TestSetBranchRejectsStepFromAnotherCampaign(t *testing.T) {
+ f := newBranchFixture(t, "draft")
+ _, err := f.svc.SetBranch(context.Background(), f.ws, f.campaign, BranchInput{StepID: uuid.New(), Condition: "always"})
+ if !errors.Is(err, ErrNotFound) {
+ t.Fatalf("want ErrNotFound, got %v", err)
+ }
+ if f.branches.upserts != 0 {
+ t.Fatal("nothing may be written for a foreign step")
+ }
+}
+
+func TestSetBranchRejectsTargetOutsideCampaign(t *testing.T) {
+ f := newBranchFixture(t, "draft")
+ foreign := uuid.New()
+ _, err := f.svc.SetBranch(context.Background(), f.ws, f.campaign, BranchInput{
+ StepID: f.step[0], Condition: "replied", WithinDays: ptr(int32(2)), NoStepID: &foreign,
+ })
+ var te *seqgraph.TargetError
+ if !errors.As(err, &te) || te.Target != foreign {
+ t.Fatalf("want TargetError naming %v, got %v", foreign, err)
+ }
+ if f.branches.upserts != 0 {
+ t.Fatal("an unknown target must be refused before any write")
+ }
+}
+
+func TestSetBranchRejectsCycle(t *testing.T) {
+ f := newBranchFixture(t, "running")
+ // 3 -> 1 closes 1 -> 2 -> 3 (implicit fall-through).
+ _, err := f.svc.SetBranch(context.Background(), f.ws, f.campaign, BranchInput{
+ StepID: f.step[2], Condition: "always", YesStepID: &f.step[0],
+ })
+ var cyc *seqgraph.CycleError
+ if !errors.As(err, &cyc) {
+ t.Fatalf("want CycleError, got %v", err)
+ }
+ if len(f.branches.branches) != 0 {
+ t.Fatal("a refused edit must leave the graph as it was")
+ }
+}
+
+func TestSetBranchShapeErrors(t *testing.T) {
+ cases := map[string]struct {
+ in func(f branchFixture) BranchInput
+ code string
+ }{
+ "unknown condition": {func(f branchFixture) BranchInput {
+ return BranchInput{StepID: f.step[0], Condition: "bounced", WithinDays: ptr(int32(1))}
+ }, seqgraph.CodeInvalidCondition},
+ "missing window": {func(f branchFixture) BranchInput {
+ return BranchInput{StepID: f.step[0], Condition: "clicked"}
+ }, seqgraph.CodeInvalidWithinDays},
+ "explicit zero window on always": {func(f branchFixture) BranchInput {
+ return BranchInput{StepID: f.step[0], Condition: "always", WithinDays: ptr(int32(0))}
+ }, seqgraph.CodeInvalidWithinDays},
+ "no exit on always": {func(f branchFixture) BranchInput {
+ return BranchInput{StepID: f.step[0], Condition: "always", NoStepID: &f.step[1]}
+ }, seqgraph.CodeNoExitNotAllowed},
+ "label on an open": {func(f branchFixture) BranchInput {
+ return BranchInput{StepID: f.step[0], Condition: "opened", WithinDays: ptr(int32(1)), ReplyLabelKey: ptr("positive")}
+ }, seqgraph.CodeLabelNotAllowed},
+ "label that stops the sequence": {func(f branchFixture) BranchInput {
+ return BranchInput{StepID: f.step[0], Condition: "replied", WithinDays: ptr(int32(1)), ReplyLabelKey: ptr("positive")}
+ }, seqgraph.CodeLabelStopsSequence},
+ "label that does not exist": {func(f branchFixture) BranchInput {
+ return BranchInput{StepID: f.step[0], Condition: "replied", WithinDays: ptr(int32(1)), ReplyLabelKey: ptr("ghost")}
+ }, seqgraph.CodeLabelNotAllowed},
+ "self loop": {func(f branchFixture) BranchInput {
+ return BranchInput{StepID: f.step[1], Condition: "not_opened", WithinDays: ptr(int32(1)), YesStepID: &f.step[1]}
+ }, seqgraph.CodeCycle},
+ }
+ for name, tc := range cases {
+ t.Run(name, func(t *testing.T) {
+ f := newBranchFixture(t, "draft")
+ _, err := f.svc.SetBranch(context.Background(), f.ws, f.campaign, tc.in(f))
+ if got := seqgraph.CodeOf(err); got != tc.code {
+ t.Fatalf("code = %q (%v), want %q", got, err, tc.code)
+ }
+ if f.branches.upserts != 0 {
+ t.Fatal("a malformed branch must not be written")
+ }
+ })
+ }
+}
+
+// Removing a router restores the step's fall-through, which is itself an edge:
+// here step 2 routes back to step 1, which is only acyclic because step 1 ends
+// explicitly. Deleting step 1's router would restore 1 -> 2 and close the loop.
+func TestDeleteBranchRefusedWhenFallThroughClosesLoop(t *testing.T) {
+ f := newBranchFixture(t, "running")
+ ctx := context.Background()
+ if _, err := f.svc.SetBranch(ctx, f.ws, f.campaign, BranchInput{StepID: f.step[0], Condition: "always"}); err != nil {
+ t.Fatalf("1 ends: %v", err)
+ }
+ if _, err := f.svc.SetBranch(ctx, f.ws, f.campaign, BranchInput{
+ StepID: f.step[1], Condition: "always", YesStepID: &f.step[0],
+ }); err != nil {
+ t.Fatalf("2 -> 1 while 1 ends is acyclic: %v", err)
+ }
+ err := f.svc.DeleteBranch(ctx, f.ws, f.campaign, f.step[0], BranchPrecondition{})
+ if seqgraph.CodeOf(err) != seqgraph.CodeCycle {
+ t.Fatalf("want a cycle refusal, got %v", err)
+ }
+ if _, still := f.branches.branches[f.step[0]]; !still {
+ t.Fatal("the refused delete must be rolled back")
+ }
+}
+
+func TestDeleteBranchIsIdempotent(t *testing.T) {
+ f := newBranchFixture(t, "running")
+ if err := f.svc.DeleteBranch(context.Background(), f.ws, f.campaign, f.step[0], BranchPrecondition{}); err != nil {
+ t.Fatalf("deleting a router that does not exist is a no-op: %v", err)
+ }
+}
+
+func TestGraphReturnsStepsAndBranches(t *testing.T) {
+ f := newBranchFixture(t, "running")
+ f.branches.branches = map[uuid.UUID]gen.SequenceStepBranch{
+ f.step[0]: {StepID: f.step[0], Condition: "always", YesStepID: pgtype.UUID{Bytes: f.step[2], Valid: true}},
+ }
+ g, err := f.svc.Graph(context.Background(), f.ws, f.campaign)
+ if err != nil {
+ t.Fatalf("Graph: %v", err)
+ }
+ if len(g.Steps) != 3 || len(g.Branches) != 1 {
+ t.Fatalf("graph = %+v", g)
+ }
+ resp := toGraphResponse(f.campaign, g)
+ if resp.EntryStepID == nil || *resp.EntryStepID != f.step[0].String() {
+ t.Fatalf("entry = %v", resp.EntryStepID)
+ }
+ if resp.Nodes[0].Branch == nil || resp.Nodes[0].Branch.YesStepID == nil || *resp.Nodes[0].Branch.YesStepID != f.step[2].String() {
+ t.Fatalf("node 1 branch = %+v", resp.Nodes[0].Branch)
+ }
+ // The fall-through is served whether or not a branch overrides it.
+ if resp.Nodes[0].DefaultNextStepID == nil || *resp.Nodes[0].DefaultNextStepID != f.step[1].String() {
+ t.Fatalf("node 1 default next = %v", resp.Nodes[0].DefaultNextStepID)
+ }
+ if resp.Nodes[2].DefaultNextStepID != nil {
+ t.Fatalf("the last step has no fall-through, got %v", *resp.Nodes[2].DefaultNextStepID)
+ }
+}
+
+func TestGraphRejectsMissingCampaign(t *testing.T) {
+ svc := NewService(&fakeStore{}, fakeChecker{err: pgx.ErrNoRows}, &fakeVariantStore{}, &fakeBranchStore{})
+ if _, err := svc.Graph(context.Background(), uuid.New(), uuid.New()); !errors.Is(err, ErrCampaignNotFound) {
+ t.Fatalf("want ErrCampaignNotFound, got %v", err)
+ }
+}
+
+// A linear campaign (no routers) validates under every structural edit exactly
+// as before branching existed: the check a delete/reorder now commits under
+// never refuses a graph with no branches.
+func TestCheckGraphAcceptsEveryLinearCampaign(t *testing.T) {
+ var steps []gen.SequenceStep
+ for i := range 5 {
+ steps = append(steps, gen.SequenceStep{ID: uuid.New(), StepOrder: int32(i*2 + 1)}) // gaps too
+ }
+ if err := checkGraph(steps, nil); err != nil {
+ t.Fatalf("linear campaign refused: %v", err)
+ }
+ if err := checkGraph(nil, nil); err != nil {
+ t.Fatalf("empty campaign refused: %v", err)
+ }
+}
+
+// The precondition is enforced by the store, in the write; the service's job is
+// to hand it over untouched on both writes, and to refuse a malformed branch
+// before any precondition is consulted.
+func TestBranchPreconditionReachesTheStore(t *testing.T) {
+ ctx := context.Background()
+ f := newBranchFixture(t, "running")
+ at := time.Date(2026, 9, 23, 16, 10, 44, 123456000, time.UTC)
+
+ if _, err := f.svc.SetBranch(ctx, f.ws, f.campaign, BranchInput{
+ StepID: f.step[0], Condition: "always", Expect: ExpectNoBranch(),
+ }); err != nil {
+ t.Fatalf("SetBranch: %v", err)
+ }
+ if err := f.svc.DeleteBranch(ctx, f.ws, f.campaign, f.step[0], ExpectBranchAt(at)); err != nil {
+ t.Fatalf("DeleteBranch: %v", err)
+ }
+ want := []BranchPrecondition{ExpectNoBranch(), ExpectBranchAt(at)}
+ if !slices.Equal(f.branches.expects, want) {
+ t.Fatalf("store saw %+v, want %+v", f.branches.expects, want)
+ }
+
+ f = newBranchFixture(t, "running")
+ if _, err := f.svc.SetBranch(ctx, f.ws, f.campaign, BranchInput{
+ StepID: f.step[0], Condition: "bounced", Expect: ExpectBranchAt(at),
+ }); seqgraph.CodeOf(err) != seqgraph.CodeInvalidCondition {
+ t.Fatalf("a malformed branch is refused as malformed, got %v", err)
+ }
+ if len(f.branches.expects) != 0 {
+ t.Fatal("a malformed branch must not reach the store")
+ }
+}
diff --git a/internal/app/sequencestep/branchhandler.go b/internal/app/sequencestep/branchhandler.go
new file mode 100644
index 00000000..146fcaf4
--- /dev/null
+++ b/internal/app/sequencestep/branchhandler.go
@@ -0,0 +1,332 @@
+package sequencestep
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/google/uuid"
+
+ "github.com/inroad/inroad/internal/app/auth"
+ "github.com/inroad/inroad/internal/platform/db/gen"
+ "github.com/inroad/inroad/internal/platform/httpx"
+ "github.com/inroad/inroad/internal/platform/seqgraph"
+)
+
+// branchRequest is the PUT body (StepBranchRequest in api/openapi.yaml). A null
+// or absent exit ends the path.
+type branchRequest struct {
+ Condition string `json:"condition"`
+ WithinDays *int32 `json:"within_days"`
+ ReplyLabelKey *string `json:"reply_label_key"`
+ YesStepID *uuid.UUID `json:"yes_step_id"`
+ NoStepID *uuid.UUID `json:"no_step_id"`
+ // ExpectedUpdatedAt is the optimistic-concurrency precondition, and its
+ // three states mean three different things: absent = none, null = "expect no
+ // branch", a timestamp = "expect the branch at this updated_at".
+ ExpectedUpdatedAt presentField `json:"expected_updated_at"`
+}
+
+// presentField records whether a JSON field was sent at all, and its raw value
+// if so. encoding/json calls UnmarshalJSON for an explicit null on a non-pointer
+// field, so this is the one way to tell an absent field from a null one.
+type presentField struct {
+ set bool
+ raw json.RawMessage
+}
+
+func (f *presentField) UnmarshalJSON(b []byte) error {
+ f.set = true
+ f.raw = append(f.raw[:0], b...)
+ return nil
+}
+
+// expectedUpdatedAtParam is the DELETE precondition's query parameter, named
+// like the PUT body field it mirrors.
+const expectedUpdatedAtParam = "expected_updated_at"
+
+// bodyPrecondition reads expected_updated_at from a PUT body.
+func bodyPrecondition(f presentField) (BranchPrecondition, error) {
+ if !f.set {
+ return BranchPrecondition{}, nil
+ }
+ if string(f.raw) == "null" {
+ return ExpectNoBranch(), nil
+ }
+ var raw string
+ if err := json.Unmarshal(f.raw, &raw); err != nil {
+ return BranchPrecondition{}, errors.New("expected_updated_at must be a timestamp string or null")
+ }
+ at, err := parseExpectedUpdatedAt(raw)
+ if err != nil {
+ return BranchPrecondition{}, err
+ }
+ return ExpectBranchAt(at), nil
+}
+
+// queryPrecondition reads expected_updated_at from a DELETE's query string.
+// There is no "expect no branch" form: deleting nothing needs no guard.
+func queryPrecondition(r *http.Request) (BranchPrecondition, error) {
+ q := r.URL.Query()
+ if !q.Has(expectedUpdatedAtParam) {
+ return BranchPrecondition{}, nil
+ }
+ at, err := parseExpectedUpdatedAt(q.Get(expectedUpdatedAtParam))
+ if err != nil {
+ return BranchPrecondition{}, err
+ }
+ return ExpectBranchAt(at), nil
+}
+
+// parseExpectedUpdatedAt accepts an updated_at exactly as toBranchResponse
+// serializes it. The column is microsecond-precision, so a value with finer
+// digits cannot be one the API returned; it is refused rather than truncated,
+// because truncating would let a value that differs from the stored one match.
+func parseExpectedUpdatedAt(raw string) (time.Time, error) {
+ at, err := time.Parse(time.RFC3339Nano, raw)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("expected_updated_at must be an RFC3339 timestamp: %w", err)
+ }
+ if at.Nanosecond()%int(time.Microsecond) != 0 {
+ return time.Time{}, errors.New("expected_updated_at has sub-microsecond digits; send updated_at back exactly as it was returned")
+ }
+ return at, nil
+}
+
+// branchResponse is the wire shape of one router (StepBranch).
+type branchResponse struct {
+ StepID string `json:"step_id"`
+ Condition string `json:"condition"`
+ WithinDays *int32 `json:"within_days"`
+ ReplyLabelKey *string `json:"reply_label_key"`
+ YesStepID *string `json:"yes_step_id"`
+ NoStepID *string `json:"no_step_id"`
+ UpdatedAt string `json:"updated_at"`
+}
+
+// graphNodeResponse is one step as a graph node (CampaignGraphNode).
+type graphNodeResponse struct {
+ StepID string `json:"step_id"`
+ StepOrder int32 `json:"step_order"`
+ // DefaultNextStepID is where the step goes when it has NO branch: the next
+ // step by step_order, or null for the last step. Served rather than left for
+ // the client to derive so the canvas draws the same fall-through edge the
+ // send path follows.
+ DefaultNextStepID *string `json:"default_next_step_id"`
+ Branch *branchResponse `json:"branch"`
+}
+
+// graphResponse is GET /campaigns/{id}/graph (CampaignGraph).
+type graphResponse struct {
+ CampaignID string `json:"campaign_id"`
+ EntryStepID *string `json:"entry_step_id"`
+ Nodes []graphNodeResponse `json:"nodes"`
+}
+
+// graphErrorResponse is every graph validation failure (BranchValidationError):
+// the usual human message plus a stable code, and — for a loop — the steps on
+// it, so the canvas can highlight the offending edges.
+type graphErrorResponse struct {
+ Error string `json:"error"`
+ Code string `json:"code"`
+ StepIDs []string `json:"step_ids,omitempty"`
+}
+
+// CodeBranchChanged is the BranchValidationError code of a refused
+// expected_updated_at precondition (409).
+const CodeBranchChanged = "branch_changed"
+
+// branchChangedResponse is the 409 for a refused precondition: the same error
+// shape plus the branch as it is now. current is always present — null means
+// the step now has no branch — so it is not omitempty.
+type branchChangedResponse struct {
+ Error string `json:"error"`
+ Code string `json:"code"`
+ Current *branchResponse `json:"current"`
+}
+
+// Graph handles GET /campaigns/{id}/graph.
+func (h *Handler) Graph(w http.ResponseWriter, r *http.Request) {
+ ws, ok := auth.WorkspaceID(w, r)
+ if !ok {
+ return
+ }
+ campaignID, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ httpx.Error(w, http.StatusBadRequest, "bad campaign id")
+ return
+ }
+ g, err := h.svc.Graph(r.Context(), ws, campaignID)
+ if err != nil {
+ writeGraphError(w, err, "could not load the sequence graph")
+ return
+ }
+ httpx.JSON(w, http.StatusOK, toGraphResponse(campaignID, g))
+}
+
+// SetBranch handles PUT /campaigns/{id}/steps/{stepId}/branch.
+func (h *Handler) SetBranch(w http.ResponseWriter, r *http.Request) {
+ ws, campaignID, stepID, ok := campaignAndStepIDs(w, r)
+ if !ok {
+ return
+ }
+ var req branchRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ httpx.Error(w, http.StatusBadRequest, "invalid json")
+ return
+ }
+ expect, err := bodyPrecondition(req.ExpectedUpdatedAt)
+ if err != nil {
+ httpx.Error(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ // An empty label is "no label", not a label whose key is "".
+ if req.ReplyLabelKey != nil && *req.ReplyLabelKey == "" {
+ req.ReplyLabelKey = nil
+ }
+ b, err := h.svc.SetBranch(r.Context(), ws, campaignID, BranchInput{
+ StepID: stepID, Condition: req.Condition, WithinDays: req.WithinDays,
+ ReplyLabelKey: req.ReplyLabelKey, YesStepID: req.YesStepID, NoStepID: req.NoStepID,
+ Expect: expect,
+ })
+ if err != nil {
+ writeGraphError(w, err, "could not save branch")
+ return
+ }
+ httpx.JSON(w, http.StatusOK, toBranchResponse(b))
+}
+
+// DeleteBranch handles DELETE /campaigns/{id}/steps/{stepId}/branch.
+func (h *Handler) DeleteBranch(w http.ResponseWriter, r *http.Request) {
+ ws, campaignID, stepID, ok := campaignAndStepIDs(w, r)
+ if !ok {
+ return
+ }
+ expect, err := queryPrecondition(r)
+ if err != nil {
+ httpx.Error(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ if err := h.svc.DeleteBranch(r.Context(), ws, campaignID, stepID, expect); err != nil {
+ writeGraphError(w, err, "could not remove branch")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// campaignAndStepIDs reads the pinned workspace and the two path ids, writing
+// the error response itself when any is missing or malformed.
+func campaignAndStepIDs(w http.ResponseWriter, r *http.Request) (ws, campaignID, stepID uuid.UUID, ok bool) {
+ ws, ok = auth.WorkspaceID(w, r)
+ if !ok {
+ return uuid.Nil, uuid.Nil, uuid.Nil, false
+ }
+ campaignID, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ httpx.Error(w, http.StatusBadRequest, "bad campaign id")
+ return uuid.Nil, uuid.Nil, uuid.Nil, false
+ }
+ stepID, err = uuid.Parse(chi.URLParam(r, "stepId"))
+ if err != nil {
+ httpx.Error(w, http.StatusBadRequest, "bad step id")
+ return uuid.Nil, uuid.Nil, uuid.Nil, false
+ }
+ return ws, campaignID, stepID, true
+}
+
+// writeGraphError maps every error a graph read or write can return.
+//
+// A malformed branch (seqgraph shape codes) is 400, like any other invalid
+// body. A well-formed edit the GRAPH refuses — an exit to a step outside the
+// campaign, or a loop — is 422: the request was understood and is the wrong
+// thing to commit. Both carry `code`. A refused expected_updated_at is 409
+// (code branch_changed) with the current branch.
+func writeGraphError(w http.ResponseWriter, err error, fallback string) {
+ var cycle *seqgraph.CycleError
+ var changed *BranchChangedError
+ switch code := seqgraph.CodeOf(err); {
+ case errors.As(err, &changed):
+ resp := branchChangedResponse{Error: changed.Error(), Code: CodeBranchChanged}
+ if changed.Current != nil {
+ current := toBranchResponse(*changed.Current)
+ resp.Current = ¤t
+ }
+ httpx.JSON(w, http.StatusConflict, resp)
+ case errors.Is(err, ErrCampaignNotFound):
+ httpx.Error(w, http.StatusNotFound, "campaign not found")
+ case errors.Is(err, ErrNotFound), errors.Is(err, ErrBranchConflict):
+ httpx.Error(w, http.StatusNotFound, "step not found")
+ case errors.Is(err, ErrCampaignNotDraft):
+ httpx.Error(w, http.StatusConflict, "steps can only be changed structurally while the campaign is draft")
+ case errors.Is(err, ErrTargetGone):
+ httpx.JSON(w, http.StatusUnprocessableEntity, graphErrorResponse{Error: err.Error(), Code: seqgraph.CodeUnknownTarget})
+ case errors.As(err, &cycle):
+ httpx.JSON(w, http.StatusUnprocessableEntity, graphErrorResponse{
+ Error: err.Error(), Code: code, StepIDs: uuidStrings(cycle.StepIDs),
+ })
+ case code == seqgraph.CodeUnknownTarget || code == seqgraph.CodeUnknownStep:
+ httpx.JSON(w, http.StatusUnprocessableEntity, graphErrorResponse{Error: err.Error(), Code: code})
+ case code != "":
+ httpx.JSON(w, http.StatusBadRequest, graphErrorResponse{Error: err.Error(), Code: code})
+ default:
+ httpx.Error(w, http.StatusInternalServerError, fallback)
+ }
+}
+
+func toGraphResponse(campaignID uuid.UUID, g Graph) graphResponse {
+ model := BuildGraph(g.Steps, g.Branches)
+ byStep := make(map[uuid.UUID]gen.SequenceStepBranch, len(g.Branches))
+ for _, b := range g.Branches {
+ byStep[b.StepID] = b
+ }
+ out := graphResponse{CampaignID: campaignID.String(), Nodes: make([]graphNodeResponse, 0, len(g.Steps))}
+ if entry, ok := model.Entry(); ok {
+ out.EntryStepID = uuidString(entry.ID)
+ }
+ for _, st := range g.Steps {
+ node := graphNodeResponse{StepID: st.ID.String(), StepOrder: st.StepOrder}
+ if next, ok := model.DefaultNext(st.ID); ok {
+ node.DefaultNextStepID = uuidString(next.ID)
+ }
+ if b, ok := byStep[st.ID]; ok {
+ resp := toBranchResponse(b)
+ node.Branch = &resp
+ }
+ out.Nodes = append(out.Nodes, node)
+ }
+ return out
+}
+
+func toBranchResponse(b gen.SequenceStepBranch) branchResponse {
+ out := branchResponse{
+ StepID: b.StepID.String(), Condition: b.Condition,
+ WithinDays: b.WithinDays, ReplyLabelKey: b.ReplyLabelKey,
+ // Full precision: updated_at is the concurrency token a client sends
+ // back as expected_updated_at, and a value rounded to the second would
+ // never match the stored microseconds.
+ UpdatedAt: b.UpdatedAt.Time.UTC().Format(time.RFC3339Nano),
+ }
+ if b.YesStepID.Valid {
+ out.YesStepID = uuidString(b.YesStepID.Bytes)
+ }
+ if b.NoStepID.Valid {
+ out.NoStepID = uuidString(b.NoStepID.Bytes)
+ }
+ return out
+}
+
+func uuidString(id uuid.UUID) *string {
+ s := id.String()
+ return &s
+}
+
+func uuidStrings(ids []uuid.UUID) []string {
+ out := make([]string, len(ids))
+ for i, id := range ids {
+ out[i] = id.String()
+ }
+ return out
+}
diff --git a/internal/app/sequencestep/branchhandler_test.go b/internal/app/sequencestep/branchhandler_test.go
new file mode 100644
index 00000000..80d649e8
--- /dev/null
+++ b/internal/app/sequencestep/branchhandler_test.go
@@ -0,0 +1,183 @@
+package sequencestep
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+
+ "github.com/inroad/inroad/internal/platform/db/gen"
+ "github.com/inroad/inroad/internal/platform/seqgraph"
+)
+
+// The status + body shape the canvas keys its error handling on.
+func TestWriteGraphErrorContract(t *testing.T) {
+ loop := []uuid.UUID{uuid.New(), uuid.New()}
+ cases := []struct {
+ name string
+ err error
+ status int
+ code string
+ stepIDs int
+ hasError bool
+ }{
+ {"shape", &seqgraph.ShapeError{Code: seqgraph.CodeInvalidWithinDays, Msg: "x"}, http.StatusBadRequest, seqgraph.CodeInvalidWithinDays, 0, true},
+ {"cycle", &seqgraph.CycleError{StepIDs: loop}, http.StatusUnprocessableEntity, seqgraph.CodeCycle, 2, true},
+ {"wrapped cycle", fmt.Errorf("commit: %w", &seqgraph.CycleError{StepIDs: loop}), http.StatusUnprocessableEntity, seqgraph.CodeCycle, 2, true},
+ {"unknown target", &seqgraph.TargetError{StepID: uuid.New(), Target: uuid.New()}, http.StatusUnprocessableEntity, seqgraph.CodeUnknownTarget, 0, true},
+ {"target gone", ErrTargetGone, http.StatusUnprocessableEntity, seqgraph.CodeUnknownTarget, 0, true},
+ {"campaign missing", ErrCampaignNotFound, http.StatusNotFound, "", 0, true},
+ {"step missing", ErrNotFound, http.StatusNotFound, "", 0, true},
+ {"not draft", ErrCampaignNotDraft, http.StatusConflict, "", 0, true},
+ {"unexpected", errors.New("db down"), http.StatusInternalServerError, "", 0, true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ writeGraphError(rec, tc.err, "fallback")
+ if rec.Code != tc.status {
+ t.Fatalf("status = %d, want %d", rec.Code, tc.status)
+ }
+ var body graphErrorResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("body %q: %v", rec.Body.String(), err)
+ }
+ if body.Code != tc.code || len(body.StepIDs) != tc.stepIDs || (tc.hasError && body.Error == "") {
+ t.Fatalf("body = %+v", body)
+ }
+ })
+ }
+}
+
+// expected_updated_at has three states in a PUT body, and each means something
+// different: confusing absent with null would turn every legacy write into a
+// create-only write, and confusing null with absent would drop the guard.
+func TestBodyPreconditionDistinguishesAbsentNullAndTimestamp(t *testing.T) {
+ at := time.Date(2026, 9, 23, 16, 10, 44, 120000000, time.UTC)
+ cases := []struct {
+ body string
+ want BranchPrecondition
+ }{
+ {`{"condition":"always"}`, BranchPrecondition{}},
+ {`{"condition":"always","expected_updated_at":null}`, ExpectNoBranch()},
+ {`{"condition":"always","expected_updated_at":"2026-09-23T16:10:44.12Z"}`, ExpectBranchAt(at)},
+ }
+ for _, tc := range cases {
+ var req branchRequest
+ if err := json.Unmarshal([]byte(tc.body), &req); err != nil {
+ t.Fatalf("%s: %v", tc.body, err)
+ }
+ got, err := bodyPrecondition(req.ExpectedUpdatedAt)
+ if err != nil {
+ t.Fatalf("%s: %v", tc.body, err)
+ }
+ if got.kind != tc.want.kind || !got.updatedAt.Equal(tc.want.updatedAt) {
+ t.Fatalf("%s: got %+v, want %+v", tc.body, got, tc.want)
+ }
+ }
+}
+
+func TestBodyPreconditionRefusesWhatCannotBeAStoredValue(t *testing.T) {
+ for _, raw := range []string{
+ `12345`,
+ `"yesterday"`,
+ `""`,
+ // Nanoseconds: the column stores microseconds, so this is not a value the
+ // API returned, and truncating it would let a DIFFERENT value match.
+ `"2026-09-23T16:10:44.123456789Z"`,
+ } {
+ var req branchRequest
+ if err := json.Unmarshal([]byte(`{"expected_updated_at":`+raw+`}`), &req); err != nil {
+ t.Fatalf("%s: decode: %v", raw, err)
+ }
+ if _, err := bodyPrecondition(req.ExpectedUpdatedAt); err == nil {
+ t.Errorf("%s: accepted", raw)
+ }
+ }
+}
+
+func TestQueryPrecondition(t *testing.T) {
+ at := time.Date(2026, 9, 23, 16, 10, 44, 123456000, time.UTC)
+ req := func(query string) *http.Request {
+ return httptest.NewRequestWithContext(t.Context(), http.MethodDelete, "/campaigns/x/steps/y/branch"+query, http.NoBody)
+ }
+ if got, err := queryPrecondition(req("")); err != nil || got != (BranchPrecondition{}) {
+ t.Fatalf("absent: %+v, %v", got, err)
+ }
+ got, err := queryPrecondition(req("?expected_updated_at=" + url.QueryEscape("2026-09-23T16:10:44.123456Z")))
+ if err != nil || got.kind != preconditionAt || !got.updatedAt.Equal(at) {
+ t.Fatalf("timestamp: %+v, %v", got, err)
+ }
+ // An offset's '+' must be escaped; unescaped it decodes to a space and the
+ // value is refused rather than misread.
+ for _, q := range []string{"?expected_updated_at=", "?expected_updated_at=nope", "?expected_updated_at=2026-09-23T18:10:44+02:00"} {
+ if _, err := queryPrecondition(req(q)); err == nil {
+ t.Errorf("%q accepted", q)
+ }
+ }
+}
+
+// updated_at is the token a client echoes back, so what the API serializes must
+// parse to exactly the stored instant — including microseconds that end in
+// zeros (RFC3339Nano trims them) and a whole second (no fraction at all).
+func TestUpdatedAtRoundTripsExactly(t *testing.T) {
+ for _, nanos := range []int{0, 100000000, 123456000, 1000, 999999000} {
+ stored := time.Date(2026, 9, 23, 16, 10, 44, nanos, time.FixedZone("x", 3600))
+ resp := toBranchResponse(gen.SequenceStepBranch{
+ StepID: uuid.New(), Condition: "always", UpdatedAt: pgtype.Timestamptz{Time: stored, Valid: true},
+ })
+ back, err := parseExpectedUpdatedAt(resp.UpdatedAt)
+ if err != nil {
+ t.Fatalf("%q does not parse back: %v", resp.UpdatedAt, err)
+ }
+ if !back.Equal(stored) {
+ t.Fatalf("%q round-trips to %v, want %v", resp.UpdatedAt, back, stored)
+ }
+ }
+}
+
+// A refused precondition is 409 with the branch that won; when the step has no
+// branch any more, current is an explicit null, not a missing key.
+func TestWriteGraphErrorBranchChanged(t *testing.T) {
+ step := uuid.New()
+ row := gen.SequenceStepBranch{StepID: step, Condition: "always", UpdatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}}
+ cases := []struct {
+ name string
+ err error
+ current bool
+ }{
+ {"replaced", &BranchChangedError{Current: &row}, true},
+ {"removed", fmt.Errorf("tx: %w", &BranchChangedError{}), false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ writeGraphError(rec, tc.err, "fallback")
+ if rec.Code != http.StatusConflict {
+ t.Fatalf("status = %d, want 409", rec.Code)
+ }
+ var body map[string]json.RawMessage
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if string(body["code"]) != `"branch_changed"` || len(body["error"]) < 3 {
+ t.Fatalf("body = %s", rec.Body.String())
+ }
+ raw, present := body["current"]
+ if !present {
+ t.Fatalf("current must always be present: %s", rec.Body.String())
+ }
+ if tc.current != (string(raw) != "null") || (tc.current && !strings.Contains(string(raw), step.String())) {
+ t.Fatalf("current = %s", raw)
+ }
+ })
+ }
+}
diff --git a/internal/app/sequencestep/branchprecondition_integration_test.go b/internal/app/sequencestep/branchprecondition_integration_test.go
new file mode 100644
index 00000000..554a1e01
--- /dev/null
+++ b/internal/app/sequencestep/branchprecondition_integration_test.go
@@ -0,0 +1,311 @@
+//go:build integration
+
+package sequencestep
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/inroad/inroad/internal/platform/db/gen"
+)
+
+// wireToken is updated_at as a client holds it: serialized by the handler and
+// parsed back the way the handler parses expected_updated_at. Every test goes
+// through it, so a precision loss anywhere on the wire fails here rather than
+// only in a browser.
+func wireToken(t *testing.T, b gen.SequenceStepBranch) BranchPrecondition {
+ t.Helper()
+ at, err := parseExpectedUpdatedAt(toBranchResponse(b).UpdatedAt)
+ if err != nil {
+ t.Fatalf("updated_at %q does not parse back: %v", toBranchResponse(b).UpdatedAt, err)
+ }
+ return ExpectBranchAt(at)
+}
+
+func changedErr(t *testing.T, err error) *BranchChangedError {
+ t.Helper()
+ var changed *BranchChangedError
+ if !errors.As(err, &changed) {
+ t.Fatalf("want *BranchChangedError, got %v", err)
+ }
+ return changed
+}
+
+func (b branchIT) branchOf(t *testing.T, step uuid.UUID) *gen.SequenceStepBranch {
+ t.Helper()
+ g, err := b.svc.Graph(context.Background(), b.ws, b.campaign)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, br := range g.Branches {
+ if br.StepID == step {
+ return &br
+ }
+ }
+ return nil
+}
+
+func TestBranchPreconditionMatchingApplies(t *testing.T) {
+ b, done := newBranchIT(t, "Precondition match")
+ defer done()
+ ctx := context.Background()
+
+ created, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{
+ StepID: b.steps[0], Condition: "always", YesStepID: &b.steps[2], Expect: ExpectNoBranch(),
+ })
+ if err != nil {
+ t.Fatalf("create-if-absent on a step with no branch: %v", err)
+ }
+ replaced, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{
+ StepID: b.steps[0], Condition: "always", Expect: wireToken(t, created),
+ })
+ if err != nil {
+ t.Fatalf("replace at the current version: %v", err)
+ }
+ if replaced.YesStepID.Valid || !replaced.UpdatedAt.Time.After(created.UpdatedAt.Time) {
+ t.Fatalf("replaced = %+v (was %v)", replaced, created.UpdatedAt.Time)
+ }
+ if err := b.svc.DeleteBranch(ctx, b.ws, b.campaign, b.steps[0], wireToken(t, replaced)); err != nil {
+ t.Fatalf("delete at the current version: %v", err)
+ }
+ if got := b.branchOf(t, b.steps[0]); got != nil {
+ t.Fatalf("branch survived its delete: %+v", got)
+ }
+}
+
+// The core promise: a write made against a branch someone else has since
+// changed is refused, writes nothing, and says what the branch is now.
+func TestBranchPreconditionStaleIsRefusedWithCurrent(t *testing.T) {
+ b, done := newBranchIT(t, "Precondition stale")
+ defer done()
+ ctx := context.Background()
+
+ v1, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always", YesStepID: &b.steps[2]})
+ if err != nil {
+ t.Fatal(err)
+ }
+ stale := wireToken(t, v1)
+ // Another tab saves first.
+ v2, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always", Expect: stale})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ _, err = b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{
+ StepID: b.steps[0], Condition: "always", YesStepID: &b.steps[1], Expect: stale,
+ })
+ changed := changedErr(t, err)
+ if changed.Current == nil || changed.Current.YesStepID.Valid || !changed.Current.UpdatedAt.Time.Equal(v2.UpdatedAt.Time) {
+ t.Fatalf("current = %+v, want the second tab's save %+v", changed.Current, v2)
+ }
+ if got := b.branchOf(t, b.steps[0]); got == nil || got.YesStepID.Valid || !got.UpdatedAt.Time.Equal(v2.UpdatedAt.Time) {
+ t.Fatalf("a refused write changed the branch: %+v", got)
+ }
+
+ // "Expect no branch" against a step that now has one is the same refusal.
+ _, err = b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always", Expect: ExpectNoBranch()})
+ if changed := changedErr(t, err); changed.Current == nil || changed.Current.StepID != b.steps[0] {
+ t.Fatalf("current = %+v", changed.Current)
+ }
+
+ // Expecting a version of a branch that is gone reports current = nil.
+ if err := b.svc.DeleteBranch(ctx, b.ws, b.campaign, b.steps[0], BranchPrecondition{}); err != nil {
+ t.Fatal(err)
+ }
+ _, err = b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always", Expect: wireToken(t, v2)})
+ if changed := changedErr(t, err); changed.Current != nil {
+ t.Fatalf("current = %+v, want nil for a removed branch", changed.Current)
+ }
+ if got := b.branchOf(t, b.steps[0]); got != nil {
+ t.Fatalf("an update-if-unchanged recreated a removed branch: %+v", got)
+ }
+}
+
+// No precondition is exactly the old contract: last writer wins, delete is
+// idempotent. API clients and agents that never send the field keep working.
+func TestBranchWithoutPreconditionIsLastWriterWins(t *testing.T) {
+ b, done := newBranchIT(t, "Precondition absent")
+ defer done()
+ ctx := context.Background()
+ b.always(t, b.steps[0], &b.steps[2])
+ b.always(t, b.steps[0], nil)
+ if got := b.branchOf(t, b.steps[0]); got == nil || got.YesStepID.Valid {
+ t.Fatalf("second unconditional write did not win: %+v", got)
+ }
+ for range 2 {
+ if err := b.svc.DeleteBranch(ctx, b.ws, b.campaign, b.steps[0], BranchPrecondition{}); err != nil {
+ t.Fatalf("unconditional delete: %v", err)
+ }
+ }
+}
+
+// Two editors create a branch on the same empty step at the same moment, each
+// expecting none: exactly one wins, and the loser is told what the winner saved.
+func TestBranchCreateIfAbsentRace(t *testing.T) {
+ b, done := newBranchIT(t, "Precondition race")
+ defer done()
+ ctx := context.Background()
+
+ targets := []*uuid.UUID{&b.steps[1], &b.steps[2]}
+ rows := make([]gen.SequenceStepBranch, 2)
+ errs := make([]error, 2)
+ start := make(chan struct{})
+ var wg sync.WaitGroup
+ for i := range 2 {
+ wg.Go(func() {
+ <-start
+ rows[i], errs[i] = b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{
+ StepID: b.steps[0], Condition: "always", YesStepID: targets[i], Expect: ExpectNoBranch(),
+ })
+ })
+ }
+ close(start)
+ wg.Wait()
+
+ winner, loser := 0, 1
+ if errs[0] != nil {
+ winner, loser = 1, 0
+ }
+ if errs[winner] != nil {
+ t.Fatalf("both creates failed: %v / %v", errs[0], errs[1])
+ }
+ changed := changedErr(t, errs[loser])
+ if changed.Current == nil || changed.Current.YesStepID.Bytes != *targets[winner] {
+ t.Fatalf("loser's current = %+v, want the winner's branch", changed.Current)
+ }
+ if got := b.branchOf(t, b.steps[0]); got == nil || got.YesStepID.Bytes != *targets[winner] ||
+ !got.UpdatedAt.Time.Equal(rows[winner].UpdatedAt.Time) {
+ t.Fatalf("stored = %+v, want the winner's %+v", got, rows[winner])
+ }
+}
+
+func TestBranchDeleteWithStalePreconditionIsRefused(t *testing.T) {
+ b, done := newBranchIT(t, "Precondition delete")
+ defer done()
+ ctx := context.Background()
+ v1, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always", YesStepID: &b.steps[2]})
+ if err != nil {
+ t.Fatal(err)
+ }
+ stale := wireToken(t, v1)
+ v2, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always", Expect: stale})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ changed := changedErr(t, b.svc.DeleteBranch(ctx, b.ws, b.campaign, b.steps[0], stale))
+ if changed.Current == nil || !changed.Current.UpdatedAt.Time.Equal(v2.UpdatedAt.Time) {
+ t.Fatalf("current = %+v, want %+v", changed.Current, v2)
+ }
+ if b.branchOf(t, b.steps[0]) == nil {
+ t.Fatal("a refused delete removed the branch")
+ }
+
+ // Once someone else has removed it, a delete at the old version is refused
+ // with current = nil: the client learns it is gone rather than being told
+ // its own delete succeeded.
+ if err := b.svc.DeleteBranch(ctx, b.ws, b.campaign, b.steps[0], wireToken(t, v2)); err != nil {
+ t.Fatal(err)
+ }
+ if changed := changedErr(t, b.svc.DeleteBranch(ctx, b.ws, b.campaign, b.steps[0], wireToken(t, v2))); changed.Current != nil {
+ t.Fatalf("current = %+v, want nil", changed.Current)
+ }
+}
+
+// A precondition changes nothing about tenancy: a foreign workspace is 404
+// whatever it expects, through the service or the store directly, and a
+// precondition failure never leaks the owner's branch to it.
+func TestBranchPreconditionStaysWorkspacePinned(t *testing.T) {
+ b, done := newBranchIT(t, "Precondition tenant owner")
+ defer done()
+ ctx := context.Background()
+ owned, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ intruder, err := b.q.CreateWorkspace(ctx, "Precondition tenant intruder "+uuid.NewString())
+ if err != nil {
+ t.Fatal(err)
+ }
+ store := NewPgBranchStore(b.pool)
+ for name, expect := range map[string]BranchPrecondition{
+ "none": {}, "absent": ExpectNoBranch(), "at": wireToken(t, owned),
+ } {
+ in := BranchInput{CampaignID: b.campaign, StepID: b.steps[0], Condition: "always", YesStepID: &b.steps[2], Expect: expect}
+ if _, err := b.svc.SetBranch(ctx, intruder.ID, b.campaign, in); !errors.Is(err, ErrCampaignNotFound) {
+ t.Errorf("%s: service set: want ErrCampaignNotFound, got %v", name, err)
+ }
+ if _, err := store.UpsertBranch(ctx, intruder.ID, in, checkGraph); !errors.Is(err, ErrCampaignNotFound) {
+ t.Errorf("%s: store set: want ErrCampaignNotFound, got %v", name, err)
+ }
+ if err := b.svc.DeleteBranch(ctx, intruder.ID, b.campaign, b.steps[0], expect); !errors.Is(err, ErrCampaignNotFound) {
+ t.Errorf("%s: service delete: want ErrCampaignNotFound, got %v", name, err)
+ }
+ if err := store.DeleteBranch(ctx, intruder.ID, b.campaign, b.steps[0], expect, checkGraph); !errors.Is(err, ErrCampaignNotFound) {
+ t.Errorf("%s: store delete: want ErrCampaignNotFound, got %v", name, err)
+ }
+ }
+ if got := b.branchOf(t, b.steps[0]); got == nil || got.YesStepID.Valid || !got.UpdatedAt.Time.Equal(owned.UpdatedAt.Time) {
+ t.Fatalf("the intruder changed the owner's branch: %+v", got)
+ }
+}
+
+// updated_at is a token only if every write moves it. Back-to-back writes in
+// one tight loop (well inside a millisecond each on a local database) must each
+// see a strictly later value, and so must the ON DELETE SET NULL that deleting
+// an exit's target step performs — a write no query of ours makes.
+func TestBranchUpdatedAtAdvancesOnEveryWrite(t *testing.T) {
+ b, done := newBranchIT(t, "Precondition advances")
+ defer done()
+ ctx := context.Background()
+
+ prev, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always", YesStepID: &b.steps[2]})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i := range 20 {
+ next, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always", YesStepID: &b.steps[2]})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !next.UpdatedAt.Time.After(prev.UpdatedAt.Time) {
+ t.Fatalf("write %d: updated_at %v did not advance past %v", i, next.UpdatedAt.Time, prev.UpdatedAt.Time)
+ }
+ prev = next
+ }
+
+ // Same-transaction rewrites share now(); the trigger must still advance.
+ tx, err := b.pool.Begin(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ var first, second time.Time
+ for _, dst := range []*time.Time{&first, &second} {
+ if err := tx.QueryRow(ctx, `UPDATE sequence_step_branches SET condition = 'always' WHERE step_id = $1 RETURNING updated_at`, b.steps[0]).Scan(dst); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if !second.After(first) || !first.After(prev.UpdatedAt.Time) {
+ t.Fatalf("in one transaction: %v then %v (after %v)", first, second, prev.UpdatedAt.Time)
+ }
+ if err := tx.Rollback(ctx); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := b.svc.Delete(ctx, b.ws, b.campaign, b.steps[2]); err != nil {
+ t.Fatalf("delete target step: %v", err)
+ }
+ after := b.branchOf(t, b.steps[0])
+ if after == nil || after.YesStepID.Valid || !after.UpdatedAt.Time.After(prev.UpdatedAt.Time) {
+ t.Fatalf("after its exit's target was deleted: %+v (was %v)", after, prev.UpdatedAt.Time)
+ }
+ // So a client holding the pre-delete token is told the branch changed.
+ _, err = b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always", Expect: wireToken(t, prev)})
+ changedErr(t, err)
+}
diff --git a/internal/app/sequencestep/branchstore.go b/internal/app/sequencestep/branchstore.go
new file mode 100644
index 00000000..d70a936d
--- /dev/null
+++ b/internal/app/sequencestep/branchstore.go
@@ -0,0 +1,329 @@
+package sequencestep
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/inroad/inroad/internal/platform/db/gen"
+)
+
+// GraphCheck validates a campaign's routing graph as it would be committed: the
+// campaign's steps and branches AFTER a mutation, read inside the mutation's own
+// transaction. A non-nil error rolls the mutation back and is returned to the
+// caller unchanged.
+//
+// It is a function rather than a rule baked into the store so the store stays
+// policy-free: what makes a graph valid is the service's decision
+// (internal/platform/seqgraph), the store only guarantees the check sees exactly
+// the graph it is about to commit.
+type GraphCheck func(steps []gen.SequenceStep, branches []gen.SequenceStepBranch) error
+
+// BranchInput is one step's router as written. A nil exit ends the path.
+type BranchInput struct {
+ CampaignID uuid.UUID
+ StepID uuid.UUID
+ Condition string
+ WithinDays *int32
+ ReplyLabelKey *string
+ YesStepID *uuid.UUID
+ NoStepID *uuid.UUID
+ // Expect is the write's optimistic-concurrency precondition. The zero value
+ // is none: the write replaces whatever is there, as it always has.
+ Expect BranchPrecondition
+}
+
+// BranchPrecondition is what a branch write expects to find on the step before
+// it applies, so two editors cannot silently overwrite each other. The zero
+// value expects nothing (last writer wins); ExpectNoBranch and ExpectBranchAt
+// are the two real preconditions. A struct with unexported fields rather than a
+// *time.Time so "no precondition" and "expect no branch" cannot be confused.
+type BranchPrecondition struct {
+ kind preconditionKind
+ updatedAt time.Time
+}
+
+type preconditionKind int
+
+const (
+ preconditionNone preconditionKind = iota
+ preconditionAbsent
+ preconditionAt
+)
+
+// ExpectNoBranch applies the write only if the step has no branch: a create
+// that must not overwrite a branch someone else just made.
+func ExpectNoBranch() BranchPrecondition { return BranchPrecondition{kind: preconditionAbsent} }
+
+// ExpectBranchAt applies the write only if the step's branch exists and its
+// updated_at is exactly at (microsecond precision, as stored).
+func ExpectBranchAt(at time.Time) BranchPrecondition {
+ return BranchPrecondition{kind: preconditionAt, updatedAt: at}
+}
+
+// BranchChangedError is a write whose precondition no longer holds: the branch
+// was created, replaced or removed since the client read it. Current is the
+// branch as it is now (nil when the step has none), read in the same
+// transaction that refused the write, so the client can show what won.
+type BranchChangedError struct {
+ Current *gen.SequenceStepBranch
+}
+
+func (e *BranchChangedError) Error() string {
+ return "the branch was changed since it was loaded; reload it and try again"
+}
+
+// BranchStore is the persistence seam for routers. A distinct interface from
+// Store for the reason VariantStore is one: distinct callers, and it keeps the
+// graph rules testable without a database.
+type BranchStore interface {
+ ListBranches(ctx context.Context, ws, campaignID uuid.UUID) ([]gen.SequenceStepBranch, error)
+ // ReplyLabelStops reports whether the workspace's label with this key stops
+ // the enrollment; found=false when no such label exists.
+ ReplyLabelStops(ctx context.Context, ws uuid.UUID, key string) (stops, found bool, err error)
+ // TrackingEnabled reports whether the campaign records opens and clicks.
+ TrackingEnabled(ctx context.Context, ws, campaignID uuid.UUID) (bool, error)
+ // UpsertBranch creates or replaces the router on in.StepID, then runs check
+ // on the result, in one transaction holding the campaign's graph lock. A
+ // precondition in in.Expect that does not hold is a *BranchChangedError and
+ // writes nothing.
+ UpsertBranch(ctx context.Context, ws uuid.UUID, in BranchInput, check GraphCheck) (gen.SequenceStepBranch, error)
+ // DeleteBranch removes the router on stepID (a no-op when there is none),
+ // then runs check, in one transaction holding the campaign's graph lock.
+ // Removing a router is a graph change too: it restores the step's linear
+ // fall-through, which can close a loop. A precondition that does not hold is
+ // a *BranchChangedError and removes nothing.
+ DeleteBranch(ctx context.Context, ws, campaignID, stepID uuid.UUID, expect BranchPrecondition, check GraphCheck) error
+}
+
+// ErrBranchConflict is an upsert that matched a router row owned by a different
+// workspace or campaign. Unreachable through the service (the step is
+// ownership-checked first), and reported rather than swallowed so a future
+// caller that skips that check fails closed.
+var ErrBranchConflict = errors.New("step branch belongs to another campaign")
+
+// ErrTargetGone is an exit whose target step stopped existing between the
+// service's pre-check and the write (a concurrent delete): the composite FK
+// refused it. Reported as the same unknown-target failure the pre-check gives.
+var ErrTargetGone = errors.New("a branch exit points at a step that is no longer in this campaign")
+
+// foreignKeyViolation is PostgreSQL's SQLSTATE for a refused foreign key.
+const foreignKeyViolation = "23503"
+
+// PgBranchStore implements BranchStore over sqlc. The pool backs the
+// lock-mutate-check transaction.
+type PgBranchStore struct {
+ pool *pgxpool.Pool
+ q *gen.Queries
+}
+
+func NewPgBranchStore(pool *pgxpool.Pool) *PgBranchStore {
+ return &PgBranchStore{pool: pool, q: gen.New(pool)}
+}
+
+func (s *PgBranchStore) ListBranches(ctx context.Context, ws, campaignID uuid.UUID) ([]gen.SequenceStepBranch, error) {
+ return s.q.ListBranchesByCampaign(ctx, gen.ListBranchesByCampaignParams{CampaignID: campaignID, WorkspaceID: ws})
+}
+
+func (s *PgBranchStore) ReplyLabelStops(ctx context.Context, ws uuid.UUID, key string) (stops, found bool, err error) {
+ stops, err = s.q.ReplyLabelStopsEnrollment(ctx, gen.ReplyLabelStopsEnrollmentParams{WorkspaceID: ws, Key: key})
+ if errors.Is(err, pgx.ErrNoRows) {
+ return false, false, nil
+ }
+ if err != nil {
+ return false, false, fmt.Errorf("reply label lookup: %w", err)
+ }
+ return stops, true, nil
+}
+
+func (s *PgBranchStore) TrackingEnabled(ctx context.Context, ws, campaignID uuid.UUID) (bool, error) {
+ on, err := s.q.CampaignTrackingEnabled(ctx, gen.CampaignTrackingEnabledParams{ID: campaignID, WorkspaceID: ws})
+ if err != nil {
+ return false, fmt.Errorf("campaign tracking lookup: %w", err)
+ }
+ return on, nil
+}
+
+// UpsertBranch enforces in.Expect in the write itself — a conditional INSERT or
+// UPDATE, not a read followed by a write — so the precondition and the change
+// are one statement, under the graph lock besides.
+func (s *PgBranchStore) UpsertBranch(ctx context.Context, ws uuid.UUID, in BranchInput, check GraphCheck) (gen.SequenceStepBranch, error) {
+ var out gen.SequenceStepBranch
+ err := withGraphLock(ctx, s.pool, s.q, ws, in.CampaignID, check, func(q *gen.Queries) error {
+ row, err := writeBranch(ctx, q, ws, in)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return refusedWrite(ctx, q, ws, in)
+ }
+ var pgErr *pgconn.PgError
+ if errors.As(err, &pgErr) && pgErr.Code == foreignKeyViolation {
+ return ErrTargetGone
+ }
+ if err != nil {
+ return fmt.Errorf("upsert branch: %w", err)
+ }
+ out = row
+ return nil
+ })
+ return out, err
+}
+
+// writeBranch runs the one statement in.Expect calls for. Each returns
+// pgx.ErrNoRows when it writes nothing.
+func writeBranch(ctx context.Context, q *gen.Queries, ws uuid.UUID, in BranchInput) (gen.SequenceStepBranch, error) {
+ yes, no := nullUUID(in.YesStepID), nullUUID(in.NoStepID)
+ switch in.Expect.kind {
+ case preconditionAbsent:
+ return q.InsertBranchIfAbsent(ctx, gen.InsertBranchIfAbsentParams{
+ StepID: in.StepID, WorkspaceID: ws, CampaignID: in.CampaignID, Condition: in.Condition,
+ WithinDays: in.WithinDays, ReplyLabelKey: in.ReplyLabelKey, YesStepID: yes, NoStepID: no,
+ })
+ case preconditionAt:
+ return q.UpdateBranchIfUnchanged(ctx, gen.UpdateBranchIfUnchangedParams{
+ StepID: in.StepID, WorkspaceID: ws, CampaignID: in.CampaignID, Condition: in.Condition,
+ WithinDays: in.WithinDays, ReplyLabelKey: in.ReplyLabelKey, YesStepID: yes, NoStepID: no,
+ ExpectedUpdatedAt: timestamptz(in.Expect.updatedAt),
+ })
+ default:
+ return q.UpsertBranch(ctx, gen.UpsertBranchParams{
+ StepID: in.StepID, WorkspaceID: ws, CampaignID: in.CampaignID, Condition: in.Condition,
+ WithinDays: in.WithinDays, ReplyLabelKey: in.ReplyLabelKey, YesStepID: yes, NoStepID: no,
+ })
+ }
+}
+
+// refusedWrite explains a write that matched no row. With a precondition that
+// is the precondition failing, reported with the branch as it now is. Without
+// one — or when "expect no branch" collided with a row this workspace cannot
+// see — it is the tenant pin refusing another owner's row.
+func refusedWrite(ctx context.Context, q *gen.Queries, ws uuid.UUID, in BranchInput) error {
+ if in.Expect.kind == preconditionNone {
+ return ErrBranchConflict
+ }
+ current, err := currentBranch(ctx, q, ws, in.CampaignID, in.StepID)
+ if err != nil {
+ return err
+ }
+ if current == nil && in.Expect.kind == preconditionAbsent {
+ return ErrBranchConflict
+ }
+ return &BranchChangedError{Current: current}
+}
+
+// currentBranch reads one step's router inside the caller's transaction; nil
+// when the step has none.
+func currentBranch(ctx context.Context, q *gen.Queries, ws, campaignID, stepID uuid.UUID) (*gen.SequenceStepBranch, error) {
+ row, err := q.GetBranch(ctx, gen.GetBranchParams{StepID: stepID, CampaignID: campaignID, WorkspaceID: ws})
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("read current branch: %w", err)
+ }
+ return &row, nil
+}
+
+// DeleteBranch, like UpsertBranch, puts an "expect this version" precondition in
+// the DELETE's own WHERE clause.
+func (s *PgBranchStore) DeleteBranch(ctx context.Context, ws, campaignID, stepID uuid.UUID, expect BranchPrecondition, check GraphCheck) error {
+ return withGraphLock(ctx, s.pool, s.q, ws, campaignID, check, func(q *gen.Queries) error {
+ switch expect.kind {
+ case preconditionAt:
+ n, err := q.DeleteBranchIfUnchanged(ctx, gen.DeleteBranchIfUnchangedParams{
+ StepID: stepID, CampaignID: campaignID, WorkspaceID: ws, ExpectedUpdatedAt: timestamptz(expect.updatedAt),
+ })
+ if err != nil {
+ return fmt.Errorf("delete branch: %w", err)
+ }
+ if n == 0 {
+ return changedSince(ctx, q, ws, campaignID, stepID, false)
+ }
+ return nil
+ case preconditionAbsent:
+ // Expecting no branch, a delete has nothing to remove; it holds iff
+ // there is still none, which the graph lock keeps true to commit.
+ return changedSince(ctx, q, ws, campaignID, stepID, true)
+ default:
+ if err := q.DeleteBranch(ctx, gen.DeleteBranchParams{StepID: stepID, CampaignID: campaignID, WorkspaceID: ws}); err != nil {
+ return fmt.Errorf("delete branch: %w", err)
+ }
+ return nil
+ }
+ })
+}
+
+// changedSince reports a refused precondition with the branch as it now is (nil
+// when the step has none). absentIsFine is for "expect no branch", where
+// finding none means the precondition held after all.
+func changedSince(ctx context.Context, q *gen.Queries, ws, campaignID, stepID uuid.UUID, absentIsFine bool) error {
+ current, err := currentBranch(ctx, q, ws, campaignID, stepID)
+ if err != nil {
+ return err
+ }
+ if current == nil && absentIsFine {
+ return nil
+ }
+ return &BranchChangedError{Current: current}
+}
+
+// withGraphLock runs mutate and then check in ONE transaction that first takes
+// the campaign's graph lock (LockCampaignGraph). Serializing on the campaign is
+// what makes the check sound: two edits that are each acyclic alone can close a
+// loop together, and without the lock both would pass their own check against a
+// graph that no longer exists by the time they commit. Every write is pinned on
+// workspace_id by the queries mutate calls; the lock itself matches zero rows for
+// a campaign outside ws, which is reported as ErrCampaignNotFound.
+func withGraphLock(ctx context.Context, pool *pgxpool.Pool, q *gen.Queries, ws, campaignID uuid.UUID,
+ check GraphCheck, mutate func(q *gen.Queries) error) error {
+ tx, err := pool.Begin(ctx)
+ if err != nil {
+ return fmt.Errorf("begin graph tx: %w", err)
+ }
+ defer func() { _ = tx.Rollback(ctx) }() // no-op once committed
+ qtx := q.WithTx(tx)
+
+ if _, err := qtx.LockCampaignGraph(ctx, gen.LockCampaignGraphParams{ID: campaignID, WorkspaceID: ws}); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return ErrCampaignNotFound
+ }
+ return fmt.Errorf("lock campaign graph: %w", err)
+ }
+ if err := mutate(qtx); err != nil {
+ return err
+ }
+ if check != nil {
+ steps, err := qtx.ListStepsByCampaign(ctx, gen.ListStepsByCampaignParams{CampaignID: campaignID, WorkspaceID: ws})
+ if err != nil {
+ return fmt.Errorf("list steps: %w", err)
+ }
+ branches, err := qtx.ListBranchesByCampaign(ctx, gen.ListBranchesByCampaignParams{CampaignID: campaignID, WorkspaceID: ws})
+ if err != nil {
+ return fmt.Errorf("list branches: %w", err)
+ }
+ if err := check(steps, branches); err != nil {
+ return err
+ }
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return fmt.Errorf("commit graph tx: %w", err)
+ }
+ return nil
+}
+
+// nullUUID converts an optional id into the nullable column value.
+func nullUUID(id *uuid.UUID) pgtype.UUID {
+ if id == nil {
+ return pgtype.UUID{}
+ }
+ return pgtype.UUID{Bytes: *id, Valid: true}
+}
+
+func timestamptz(t time.Time) pgtype.Timestamptz {
+ return pgtype.Timestamptz{Time: t, Valid: true}
+}
diff --git a/internal/app/sequencestep/handler.go b/internal/app/sequencestep/handler.go
index e782c4c0..a0e09221 100644
--- a/internal/app/sequencestep/handler.go
+++ b/internal/app/sequencestep/handler.go
@@ -11,6 +11,7 @@ import (
"github.com/inroad/inroad/internal/app/auth"
"github.com/inroad/inroad/internal/platform/db/gen"
"github.com/inroad/inroad/internal/platform/httpx"
+ "github.com/inroad/inroad/internal/platform/seqgraph"
"github.com/inroad/inroad/internal/platform/validate"
)
@@ -131,6 +132,9 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
httpx.Error(w, http.StatusConflict, "steps can only be removed while the campaign is draft")
case errors.Is(err, ErrNotFound):
httpx.Error(w, http.StatusNotFound, "step not found")
+ case seqgraph.CodeOf(err) != "":
+ // The re-linked fall-through would close a loop through a branch (422).
+ writeGraphError(w, err, "could not delete step")
case err != nil:
httpx.Error(w, http.StatusInternalServerError, "could not delete step")
default:
@@ -175,6 +179,9 @@ func (h *Handler) Reorder(w http.ResponseWriter, r *http.Request) {
httpx.Error(w, http.StatusNotFound, "step not found")
case errors.Is(err, ErrInvalidOrder):
httpx.Error(w, http.StatusBadRequest, err.Error())
+ case seqgraph.CodeOf(err) != "":
+ // The new order's fall-through would close a loop through a branch (422).
+ writeGraphError(w, err, "could not reorder steps")
case err != nil:
httpx.Error(w, http.StatusInternalServerError, "could not reorder steps")
default:
diff --git a/internal/app/sequencestep/reorder_integration_test.go b/internal/app/sequencestep/reorder_integration_test.go
index e571af7c..b4db7583 100644
--- a/internal/app/sequencestep/reorder_integration_test.go
+++ b/internal/app/sequencestep/reorder_integration_test.go
@@ -4,6 +4,7 @@ package sequencestep
import (
"context"
+ "errors"
"testing"
"github.com/google/uuid"
@@ -85,7 +86,7 @@ func TestReorderPersistsNewOrder(t *testing.T) {
// Reverse the order: [3,2,1].
want := []uuid.UUID{ids[2], ids[1], ids[0]}
- got, err := store.Reorder(ctx, ws, campaignID, want)
+ got, err := store.Reorder(ctx, ws, campaignID, want, checkGraph)
if err != nil {
t.Fatalf("Reorder: %v", err)
}
@@ -109,8 +110,8 @@ func TestReorderPersistsNewOrder(t *testing.T) {
}
// TestReorderIsWorkspaceScoped proves the workspace pin: reordering under a
-// foreign workspace id touches zero rows (returns an empty list) and leaves the
-// owning workspace's step_order untouched.
+// foreign workspace id is refused (ErrCampaignNotFound, no rows returned) and
+// leaves the owning workspace's step_order untouched.
func TestReorderIsWorkspaceScoped(t *testing.T) {
ctx := context.Background()
if err := db.Migrate(dbtest.DSN(t)); err != nil {
@@ -131,9 +132,12 @@ func TestReorderIsWorkspaceScoped(t *testing.T) {
}
// Intruder attempts to reorder the owner's steps under its own workspace id.
- got, err := store.Reorder(ctx, other.ID, campaignID, []uuid.UUID{ids[2], ids[1], ids[0]})
- if err != nil {
- t.Fatalf("cross-tenant Reorder: %v", err)
+ // The graph lock is taken on (campaign, workspace) before any write, so a
+ // foreign workspace fails there — refused outright rather than, as before the
+ // lock existed, running every UPDATE against zero rows.
+ got, err := store.Reorder(ctx, other.ID, campaignID, []uuid.UUID{ids[2], ids[1], ids[0]}, checkGraph)
+ if !errors.Is(err, ErrCampaignNotFound) {
+ t.Fatalf("cross-tenant Reorder: want ErrCampaignNotFound, got %v", err)
}
if len(got) != 0 {
t.Fatalf("cross-tenant reorder returned %d rows, want 0", len(got))
diff --git a/internal/app/sequencestep/routes.go b/internal/app/sequencestep/routes.go
index 4887df03..9c8f2737 100644
--- a/internal/app/sequencestep/routes.go
+++ b/internal/app/sequencestep/routes.go
@@ -27,6 +27,15 @@ func (h *Handler) Register(r chi.Router) {
r.With(write).Put("/{id}/steps/{stepId}", h.Update)
r.With(write).Delete("/{id}/steps/{stepId}", h.Delete)
+ // Conditional branching. The graph read is the whole campaign's routing in
+ // one response; a branch is written per step, nested under the step it
+ // routes out of so the ownership check has the step id without trusting the
+ // body. Writes take campaigns:write and, like variant writes, are allowed on
+ // a running campaign — see Service.SetBranch for the mid-flight contract.
+ r.With(read).Get("/{id}/graph", h.Graph)
+ r.With(write).Put("/{id}/steps/{stepId}/branch", h.SetBranch)
+ r.With(write).Delete("/{id}/steps/{stepId}/branch", h.DeleteBranch)
+
// A/B variants. Nested under the step they belong to: a variant has no
// meaning apart from its step, and the nesting is what makes the step id
// available for the ownership check without trusting the body.
diff --git a/internal/app/sequencestep/service.go b/internal/app/sequencestep/service.go
index ff5ecb16..542a754f 100644
--- a/internal/app/sequencestep/service.go
+++ b/internal/app/sequencestep/service.go
@@ -33,13 +33,15 @@ type Service struct {
store Store
checker CampaignChecker
variants VariantStore
+ branches BranchStore
}
// NewService builds the step service. variants is a second, narrow seam for A/B
// variants: a distinct responsibility with distinct callers, and keeping it
// separate is what lets the weight invariant be tested without a database.
-func NewService(store Store, checker CampaignChecker, variants VariantStore) *Service {
- return &Service{store: store, checker: checker, variants: variants}
+// branches is the third, for the routing graph, for the same reason.
+func NewService(store Store, checker CampaignChecker, variants VariantStore, branches BranchStore) *Service {
+ return &Service{store: store, checker: checker, variants: variants, branches: branches}
}
// Create appends a step at max(step_order)+1. Structural change → requires the
@@ -82,6 +84,11 @@ func (s *Service) Update(ctx context.Context, ws, campaignID uuid.UUID, in Updat
// Delete removes a step. Structural change → requires the campaign to be
// draft (running/paused/done return 409).
+//
+// Branch exits that pointed at the deleted step become path ends (the FK nulls
+// them), and the linear fall-through re-links around the gap. That re-link can
+// close a loop through a branch elsewhere, so the delete commits only if the
+// resulting graph validates — refused with a *seqgraph.CycleError otherwise.
func (s *Service) Delete(ctx context.Context, ws, campaignID, stepID uuid.UUID) error {
if err := s.requireDraft(ctx, ws, campaignID); err != nil {
return err
@@ -89,7 +96,7 @@ func (s *Service) Delete(ctx context.Context, ws, campaignID, stepID uuid.UUID)
if err := s.assertStepInCampaign(ctx, ws, campaignID, stepID); err != nil {
return err
}
- return s.store.Delete(ctx, ws, stepID)
+ return s.store.Delete(ctx, ws, campaignID, stepID, checkGraph)
}
// Reorder rewrites the campaign's step_order to match stepIDs' order.
@@ -109,7 +116,9 @@ func (s *Service) Reorder(ctx context.Context, ws, campaignID uuid.UUID, stepIDs
if err := validatePermutation(current, stepIDs); err != nil {
return nil, err
}
- return s.store.Reorder(ctx, ws, campaignID, stepIDs)
+ // Reordering moves every linear fall-through edge, so like a delete it is
+ // committed only if the resulting graph has no loop.
+ return s.store.Reorder(ctx, ws, campaignID, stepIDs, checkGraph)
}
// validatePermutation confirms stepIDs contains each of current's ids exactly
@@ -154,9 +163,15 @@ func (s *Service) requireDraft(ctx context.Context, ws, campaignID uuid.UUID) er
// campaignID; otherwise ErrNotFound (never leaks another campaign's/tenant's
// step).
func (s *Service) assertStepInCampaign(ctx context.Context, ws, campaignID, stepID uuid.UUID) error {
+ _, err := s.stepInCampaign(ctx, ws, campaignID, stepID)
+ return err
+}
+
+// stepInCampaign is assertStepInCampaign for a caller that also needs the step.
+func (s *Service) stepInCampaign(ctx context.Context, ws, campaignID, stepID uuid.UUID) (gen.SequenceStep, error) {
st, err := s.store.Get(ctx, ws, stepID)
if err != nil || st.CampaignID != campaignID {
- return ErrNotFound
+ return gen.SequenceStep{}, ErrNotFound
}
- return nil
+ return st, nil
}
diff --git a/internal/app/sequencestep/service_test.go b/internal/app/sequencestep/service_test.go
index 18c89096..f422466c 100644
--- a/internal/app/sequencestep/service_test.go
+++ b/internal/app/sequencestep/service_test.go
@@ -33,7 +33,7 @@ func (f *fakeStore) Get(context.Context, uuid.UUID, uuid.UUID) (gen.SequenceStep
func (f *fakeStore) List(context.Context, uuid.UUID, uuid.UUID) ([]gen.SequenceStep, error) {
return f.listSteps, nil
}
-func (f *fakeStore) Reorder(_ context.Context, _, _ uuid.UUID, stepIDs []uuid.UUID) ([]gen.SequenceStep, error) {
+func (f *fakeStore) Reorder(_ context.Context, _, _ uuid.UUID, stepIDs []uuid.UUID, _ GraphCheck) ([]gen.SequenceStep, error) {
f.reorderedTo = stepIDs
out := make([]gen.SequenceStep, len(stepIDs))
for i, id := range stepIDs {
@@ -45,7 +45,10 @@ func (f *fakeStore) Update(_ context.Context, _ uuid.UUID, in UpdateInput) (gen.
f.updated = in
return gen.SequenceStep{ID: in.StepID, Subject: in.Subject}, nil
}
-func (f *fakeStore) Delete(_ context.Context, _, id uuid.UUID) error { f.deletedID = id; return nil }
+func (f *fakeStore) Delete(_ context.Context, _, _, id uuid.UUID, _ GraphCheck) error {
+ f.deletedID = id
+ return nil
+}
func (f *fakeStore) MaxStepOrder(context.Context, uuid.UUID, uuid.UUID) (int32, error) {
return f.maxOrder, nil
}
@@ -60,7 +63,7 @@ func (c fakeChecker) CampaignStatus(context.Context, uuid.UUID, uuid.UUID) (stri
}
func TestCreateRejectsNonDraftCampaign(t *testing.T) {
- svc := NewService(&fakeStore{}, fakeChecker{status: "running"}, &fakeVariantStore{})
+ svc := NewService(&fakeStore{}, fakeChecker{status: "running"}, &fakeVariantStore{}, &fakeBranchStore{})
_, err := svc.Create(context.Background(), uuid.New(), uuid.New(), CreateInput{Subject: "x", BodyText: "y"})
if !errors.Is(err, ErrCampaignNotDraft) {
t.Fatalf("want ErrCampaignNotDraft, got %v", err)
@@ -68,7 +71,7 @@ func TestCreateRejectsNonDraftCampaign(t *testing.T) {
}
func TestCreateRejectsMissingCampaign(t *testing.T) {
- svc := NewService(&fakeStore{}, fakeChecker{err: errors.New("no rows")}, &fakeVariantStore{})
+ svc := NewService(&fakeStore{}, fakeChecker{err: errors.New("no rows")}, &fakeVariantStore{}, &fakeBranchStore{})
_, err := svc.Create(context.Background(), uuid.New(), uuid.New(), CreateInput{Subject: "x", BodyText: "y"})
if !errors.Is(err, ErrCampaignNotFound) {
t.Fatalf("want ErrCampaignNotFound, got %v", err)
@@ -77,7 +80,7 @@ func TestCreateRejectsMissingCampaign(t *testing.T) {
func TestCreateAppendsAtNextOrder(t *testing.T) {
store := &fakeStore{maxOrder: 2}
- svc := NewService(store, fakeChecker{status: "draft"}, &fakeVariantStore{})
+ svc := NewService(store, fakeChecker{status: "draft"}, &fakeVariantStore{}, &fakeBranchStore{})
campaignID := uuid.New()
st, err := svc.Create(context.Background(), uuid.New(), campaignID, CreateInput{Subject: "x", BodyText: "y"})
if err != nil {
@@ -96,7 +99,7 @@ func TestUpdateAllowedOnRunningCampaign(t *testing.T) {
campaignID := uuid.New()
stepID := uuid.New()
store := &fakeStore{getStep: gen.SequenceStep{ID: stepID, CampaignID: campaignID}}
- svc := NewService(store, fakeChecker{status: "running"}, &fakeVariantStore{})
+ svc := NewService(store, fakeChecker{status: "running"}, &fakeVariantStore{}, &fakeBranchStore{})
_, err := svc.Update(context.Background(), uuid.New(), campaignID, UpdateInput{StepID: stepID, Subject: "new"})
if err != nil {
t.Fatalf("update on running should be allowed (live-reference), got %v", err)
@@ -111,7 +114,7 @@ func TestDeleteRejectsRunningCampaign(t *testing.T) {
campaignID := uuid.New()
stepID := uuid.New()
store := &fakeStore{getStep: gen.SequenceStep{ID: stepID, CampaignID: campaignID}}
- svc := NewService(store, fakeChecker{status: "running"}, &fakeVariantStore{})
+ svc := NewService(store, fakeChecker{status: "running"}, &fakeVariantStore{}, &fakeBranchStore{})
err := svc.Delete(context.Background(), uuid.New(), campaignID, stepID)
if !errors.Is(err, ErrCampaignNotDraft) {
t.Fatalf("want ErrCampaignNotDraft, got %v", err)
@@ -126,7 +129,7 @@ func TestReorderHappyPath(t *testing.T) {
store := &fakeStore{listSteps: []gen.SequenceStep{
{ID: a, StepOrder: 1}, {ID: b, StepOrder: 2}, {ID: c, StepOrder: 3},
}}
- svc := NewService(store, fakeChecker{status: "draft"}, &fakeVariantStore{})
+ svc := NewService(store, fakeChecker{status: "draft"}, &fakeVariantStore{}, &fakeBranchStore{})
newOrder := []uuid.UUID{c, a, b}
got, err := svc.Reorder(context.Background(), uuid.New(), campaignID, newOrder)
if err != nil {
@@ -156,7 +159,7 @@ func TestReorderRejectsNonPermutation(t *testing.T) {
for name, ids := range cases {
t.Run(name, func(t *testing.T) {
store := &fakeStore{listSteps: []gen.SequenceStep{{ID: a, StepOrder: 1}, {ID: b, StepOrder: 2}}}
- svc := NewService(store, fakeChecker{status: "draft"}, &fakeVariantStore{})
+ svc := NewService(store, fakeChecker{status: "draft"}, &fakeVariantStore{}, &fakeBranchStore{})
_, err := svc.Reorder(context.Background(), uuid.New(), campaignID, ids)
if !errors.Is(err, ErrInvalidOrder) {
t.Fatalf("want ErrInvalidOrder, got %v", err)
@@ -171,7 +174,7 @@ func TestReorderRejectsNonPermutation(t *testing.T) {
// Reorder is a structural edit → forbidden on a non-draft campaign (409).
func TestReorderRejectsNonDraftCampaign(t *testing.T) {
store := &fakeStore{}
- svc := NewService(store, fakeChecker{status: "running"}, &fakeVariantStore{})
+ svc := NewService(store, fakeChecker{status: "running"}, &fakeVariantStore{}, &fakeBranchStore{})
_, err := svc.Reorder(context.Background(), uuid.New(), uuid.New(), []uuid.UUID{uuid.New()})
if !errors.Is(err, ErrCampaignNotDraft) {
t.Fatalf("want ErrCampaignNotDraft, got %v", err)
@@ -183,7 +186,7 @@ func TestReorderRejectsNonDraftCampaign(t *testing.T) {
// Reorder on a missing campaign is 404, checked before any store read.
func TestReorderRejectsMissingCampaign(t *testing.T) {
- svc := NewService(&fakeStore{}, fakeChecker{err: errors.New("no rows")}, &fakeVariantStore{})
+ svc := NewService(&fakeStore{}, fakeChecker{err: errors.New("no rows")}, &fakeVariantStore{}, &fakeBranchStore{})
_, err := svc.Reorder(context.Background(), uuid.New(), uuid.New(), []uuid.UUID{uuid.New()})
if !errors.Is(err, ErrCampaignNotFound) {
t.Fatalf("want ErrCampaignNotFound, got %v", err)
@@ -197,7 +200,7 @@ func TestUpdateRejectsStepFromAnotherCampaign(t *testing.T) {
otherCampaign := uuid.New()
stepID := uuid.New()
store := &fakeStore{getStep: gen.SequenceStep{ID: stepID, CampaignID: otherCampaign}}
- svc := NewService(store, fakeChecker{status: "draft"}, &fakeVariantStore{})
+ svc := NewService(store, fakeChecker{status: "draft"}, &fakeVariantStore{}, &fakeBranchStore{})
_, err := svc.Update(context.Background(), uuid.New(), urlCampaign, UpdateInput{StepID: stepID, Subject: "x"})
if !errors.Is(err, ErrNotFound) {
t.Fatalf("want ErrNotFound for mismatched campaign, got %v", err)
diff --git a/internal/app/sequencestep/store.go b/internal/app/sequencestep/store.go
index 1046deb4..2dc762fc 100644
--- a/internal/app/sequencestep/store.go
+++ b/internal/app/sequencestep/store.go
@@ -43,13 +43,18 @@ type Store interface {
Get(ctx context.Context, ws, id uuid.UUID) (gen.SequenceStep, error)
List(ctx context.Context, ws, campaignID uuid.UUID) ([]gen.SequenceStep, error)
Update(ctx context.Context, ws uuid.UUID, in UpdateInput) (gen.SequenceStep, error)
- Delete(ctx context.Context, ws, id uuid.UUID) error
+ // Delete removes one step of campaignID, then runs check on the campaign's
+ // resulting graph, in one transaction holding the campaign's graph lock (a
+ // delete re-links the linear fall-through around the removed step, which can
+ // close a loop through a branch). A check error rolls the delete back.
+ Delete(ctx context.Context, ws, campaignID, id uuid.UUID, check GraphCheck) error
MaxStepOrder(ctx context.Context, ws, campaignID uuid.UUID) (int32, error)
- // Reorder rewrites step_order to 1..N to match stepIDs' order, in one
- // transaction, and returns the campaign's steps in the new order. Every
- // write is pinned by campaign_id AND workspace_id. Callers must pre-validate
- // that stepIDs is a permutation of the campaign's current step ids.
- Reorder(ctx context.Context, ws, campaignID uuid.UUID, stepIDs []uuid.UUID) ([]gen.SequenceStep, error)
+ // Reorder rewrites step_order to 1..N to match stepIDs' order, then runs
+ // check on the resulting graph, in one transaction holding the campaign's
+ // graph lock, and returns the campaign's steps in the new order. Every write
+ // is pinned by campaign_id AND workspace_id. Callers must pre-validate that
+ // stepIDs is a permutation of the campaign's current step ids.
+ Reorder(ctx context.Context, ws, campaignID uuid.UUID, stepIDs []uuid.UUID, check GraphCheck) ([]gen.SequenceStep, error)
}
// CampaignChecker reports a campaign's status (and existence) within the
@@ -87,8 +92,13 @@ func (s *PgStore) Update(ctx context.Context, ws uuid.UUID, in UpdateInput) (gen
Subject: in.Subject, BodyText: in.BodyText, BodyHtml: in.BodyHTML,
})
}
-func (s *PgStore) Delete(ctx context.Context, ws, id uuid.UUID) error {
- return s.q.DeleteStep(ctx, gen.DeleteStepParams{ID: id, WorkspaceID: ws})
+func (s *PgStore) Delete(ctx context.Context, ws, campaignID, id uuid.UUID, check GraphCheck) error {
+ return withGraphLock(ctx, s.pool, s.q, ws, campaignID, check, func(q *gen.Queries) error {
+ if err := q.DeleteStep(ctx, gen.DeleteStepParams{ID: id, WorkspaceID: ws}); err != nil {
+ return fmt.Errorf("delete step: %w", err)
+ }
+ return nil
+ })
}
func (s *PgStore) MaxStepOrder(ctx context.Context, ws, campaignID uuid.UUID) (int32, error) {
return s.q.MaxStepOrder(ctx, gen.MaxStepOrderParams{CampaignID: campaignID, WorkspaceID: ws})
@@ -100,36 +110,38 @@ func (s *PgStore) MaxStepOrder(ctx context.Context, ws, campaignID uuid.UUID) (i
// final 1-based position. All writes are pinned by campaign_id AND
// workspace_id, so a foreign id would update zero rows (belt-and-braces on top
// of the service's permutation check). Either every write commits or none does.
-func (s *PgStore) Reorder(ctx context.Context, ws, campaignID uuid.UUID, stepIDs []uuid.UUID) ([]gen.SequenceStep, error) {
- tx, err := s.pool.Begin(ctx)
- if err != nil {
- return nil, fmt.Errorf("begin reorder tx: %w", err)
- }
- defer func() { _ = tx.Rollback(ctx) }() // no-op once committed
- qtx := s.q.WithTx(tx)
-
- maxOrder, err := qtx.MaxStepOrder(ctx, gen.MaxStepOrderParams{CampaignID: campaignID, WorkspaceID: ws})
- if err != nil {
- return nil, fmt.Errorf("max step order: %w", err)
- }
- if err := qtx.ShiftStepOrders(ctx, gen.ShiftStepOrdersParams{
- CampaignID: campaignID, WorkspaceID: ws, StepOrder: maxOrder,
- }); err != nil {
- return nil, fmt.Errorf("shift step orders: %w", err)
- }
- for i, id := range stepIDs {
- if err := qtx.SetStepOrder(ctx, gen.SetStepOrderParams{
- ID: id, CampaignID: campaignID, WorkspaceID: ws, StepOrder: int32(i + 1),
+//
+// The transaction is the graph-lock one (withGraphLock): reordering moves the
+// linear fall-through edges, so it is a routing change like any branch edit. A
+// campaign outside ws fails the lock and returns ErrCampaignNotFound before any
+// write.
+func (s *PgStore) Reorder(ctx context.Context, ws, campaignID uuid.UUID, stepIDs []uuid.UUID, check GraphCheck) ([]gen.SequenceStep, error) {
+ var steps []gen.SequenceStep
+ err := withGraphLock(ctx, s.pool, s.q, ws, campaignID, check, func(qtx *gen.Queries) error {
+ maxOrder, err := qtx.MaxStepOrder(ctx, gen.MaxStepOrderParams{CampaignID: campaignID, WorkspaceID: ws})
+ if err != nil {
+ return fmt.Errorf("max step order: %w", err)
+ }
+ if err := qtx.ShiftStepOrders(ctx, gen.ShiftStepOrdersParams{
+ CampaignID: campaignID, WorkspaceID: ws, StepOrder: maxOrder,
}); err != nil {
- return nil, fmt.Errorf("set step order: %w", err)
+ return fmt.Errorf("shift step orders: %w", err)
}
- }
- steps, err := qtx.ListStepsByCampaign(ctx, gen.ListStepsByCampaignParams{CampaignID: campaignID, WorkspaceID: ws})
+ for i, id := range stepIDs {
+ if err := qtx.SetStepOrder(ctx, gen.SetStepOrderParams{
+ ID: id, CampaignID: campaignID, WorkspaceID: ws, StepOrder: int32(i + 1),
+ }); err != nil {
+ return fmt.Errorf("set step order: %w", err)
+ }
+ }
+ steps, err = qtx.ListStepsByCampaign(ctx, gen.ListStepsByCampaignParams{CampaignID: campaignID, WorkspaceID: ws})
+ if err != nil {
+ return fmt.Errorf("list steps: %w", err)
+ }
+ return nil
+ })
if err != nil {
- return nil, fmt.Errorf("list steps: %w", err)
- }
- if err := tx.Commit(ctx); err != nil {
- return nil, fmt.Errorf("commit reorder tx: %w", err)
+ return nil, err
}
return steps, nil
}
diff --git a/internal/app/sequencestep/variant_test.go b/internal/app/sequencestep/variant_test.go
index 0b8204a8..6703db2b 100644
--- a/internal/app/sequencestep/variant_test.go
+++ b/internal/app/sequencestep/variant_test.go
@@ -18,7 +18,7 @@ func variantFixture(baseWeight int32, variants ...Variant) (*Service, *fakeVaria
step := gen.SequenceStep{ID: testStepID, StepOrder: 1, Subject: "hi", VariantWeight: baseWeight}
store := &fakeStore{getStep: step}
vs := &fakeVariantStore{variants: variants, sent: map[uuid.UUID]int64{}}
- return NewService(store, fakeChecker{status: "running"}, vs), vs
+ return NewService(store, fakeChecker{status: "running"}, vs, &fakeBranchStore{}), vs
}
func variant(label string, weight int32) Variant {
diff --git a/internal/coreapi/coreapi.go b/internal/coreapi/coreapi.go
index ffb73632..0306f17c 100644
--- a/internal/coreapi/coreapi.go
+++ b/internal/coreapi/coreapi.go
@@ -295,9 +295,13 @@ type Client interface {
// GetStepSendJob loads everything needed to send the enrollment's next due
// step (current_step+1): resolved step content, personalization vars,
- // threading headers, cap gate, and decrypted transport. Read-only — it
- // creates no rows, so a suppressed/capped step leaves no orphan. workspaceID
- // is pinned in the SQL WHERE (defense in depth on the enrollment UUID).
+ // threading headers, cap gate, and decrypted transport. It creates no
+ // sends row (the claim does), so a suppressed/capped step leaves no orphan.
+ // It may write enrollment state the control plane decides on its own: a
+ // pool mailbox pin, and on a branched campaign a routed completion or a
+ // condition wait (ConditionPending). Each is guarded on status='active' and
+ // idempotent. workspaceID is pinned in the SQL WHERE (defense in depth on
+ // the enrollment UUID).
GetStepSendJob(ctx context.Context, enrollmentID, workspaceID string) (StepSendJob, error)
// ClaimStepSend claims one step-send for delivery (claim-before-send): the
// sends row is inserted 'sending' (fresh claim), or a STALE 'sending' lease is
@@ -730,6 +734,18 @@ type StepSendJob struct {
// whereas a pause is a condition that CLEARS. The enrollment has to wait and
// resume, so the worker defers it (see the blocked branch in advance.go).
CampaignPaused bool `json:"campaign_paused"`
+ // ConditionPending means the enrollment's next move waits on a branch
+ // condition that is not decided yet, or on a routed step that is not due
+ // yet. The control plane has ALREADY re-stamped next_due_at to RecheckAt;
+ // the worker's only job is to schedule the next advance for then.
+ //
+ // Skip is set alongside it, deliberately: a worker built before this field
+ // existed decodes it as false and must not treat the otherwise-empty job as
+ // a send. Such a worker skips instead, and the stamped next_due_at lets the
+ // sweeper re-drive the enrollment. A current worker checks ConditionPending
+ // FIRST, so it schedules the recheck rather than leaving it to the sweeper.
+ ConditionPending bool `json:"condition_pending"`
+ RecheckAt time.Time `json:"recheck_at"`
// NotDueUntil is the enrollment's persisted next_due_at, carried so the
// claim can refuse a step that is not due yet. It exists because pushing
// next_due_at out (DeferEnrollment, the out-of-office path) cannot cancel
diff --git a/internal/coreapi/inprocess/branchroute.go b/internal/coreapi/inprocess/branchroute.go
new file mode 100644
index 00000000..d9da46ca
--- /dev/null
+++ b/internal/coreapi/inprocess/branchroute.go
@@ -0,0 +1,442 @@
+package inprocess
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgtype"
+
+ "github.com/inroad/inroad/internal/app/sequencestep"
+ "github.com/inroad/inroad/internal/coreapi"
+ "github.com/inroad/inroad/internal/platform/cadence"
+ "github.com/inroad/inroad/internal/platform/db/gen"
+ "github.com/inroad/inroad/internal/platform/seqgraph"
+)
+
+// Conditional branching on the send path.
+//
+// # When this code runs
+//
+// ONLY for a campaign that has at least one branch, or for an enrollment whose
+// current wait was set by a condition (awaiting_condition_step == current_step:
+// the campaign's last branch was deleted while this contact waited on it). Every
+// other enrollment — every linear campaign — takes the original GetNextStep path
+// in localStepSendJob, untouched. See usesGraphRouting.
+//
+// # The route is re-derived, never stored
+//
+// An enrollment carries no "which branch did I take" state. On every advance the
+// route out of the step it last received is recomputed from the CURRENT graph
+// and the engagement evidence. That is sound because a verdict is a pure
+// function of events inside a fixed window (seqgraph.Evaluate): once decided it
+// cannot change, so re-deriving it later lands on the same exit.
+//
+// # Mid-flight edit rule
+//
+// Graph edits are allowed on a running campaign, and the rule for an enrollment
+// already in flight is: EVERY DECISION IS MADE AGAINST THE GRAPH AS IT IS WHEN
+// THE DECISION IS MADE. Concretely, for an enrollment whose last received step
+// is S:
+//
+// - A message already sent is never affected; nothing is re-sent or recalled.
+// - A branch added to S after S was sent applies at the next advance, with its
+// window measured from S's send (last_sent_at) — so a window that has
+// already elapsed decides immediately.
+// - A branch on S whose condition, window or exits change while the contact
+// waits is evaluated with the NEW definition at the next check. That check
+// is at most conditionRecheckInterval away while the condition is OPEN, but
+// can be later: rechecks are snapped into the campaign's send window (a
+// Friday-evening recheck lands on Monday morning), an out-of-office deferral
+// that ends later wins, and once a route is decided the next check is when
+// its target falls due — which may be days out. A lengthened window keeps
+// waiting; a shortened one decides at the next check. Until the target is
+// actually SENT, a decided route is re-derived too, so changing S's exits
+// in that interval re-routes the contact to the new exit.
+// - Known edge: the recover-forward window. If S's routed target T was
+// delivered but the cursor advance to T failed, and S's exit is changed
+// BEFORE the retry runs, the retry routes to the NEW target U and sends it
+// too — the contact receives both T and U. The window is one failed
+// transaction plus an asynq retry (seconds); closing it would mean storing
+// the route, which the design deliberately does not do.
+// - A branch removed from S while the contact waits returns S to linear
+// fall-through, and the successor still waits out its own delay from S's
+// send (awaiting_condition_step is what keeps this true once the campaign
+// has no branches left).
+// - An exit whose target step is deleted becomes an end (the FK nulls it).
+// Step deletes are draft-only, so this cannot happen to a running campaign.
+// - Once the route out of S has been decided AND its target sent, the
+// enrollment's cursor is on the target; later edits to S no longer matter.
+const (
+ // conditionRecheckInterval bounds how long an undecided condition goes
+ // unobserved. The route is taken the first time the advance runs after the
+ // deciding event, and the routed step's delay is measured from the event
+ // itself, so this only adds latency when that delay is shorter than the
+ // interval. A reply additionally nudges the enrollment (see
+ // NudgeEnrollmentAwaitingReply), so the one signal an operator expects to act
+ // on promptly does not wait for the interval.
+ conditionRecheckInterval = time.Hour
+ // routeDueTolerance absorbs the skew between the worker clock that scheduled
+ // an advance and the database clock that stamped last_sent_at, so an advance
+ // that fires on time is not bounced for being a few seconds early.
+ routeDueTolerance = time.Minute
+)
+
+// usesGraphRouting reports whether an enrollment must be routed through the
+// branch graph rather than the linear GetNextStep path.
+func usesGraphRouting(branches []gen.SequenceStepBranch, b gen.GetStepEnrollmentBundleRow) bool {
+ return len(branches) > 0 ||
+ (b.AwaitingConditionStep != nil && *b.AwaitingConditionStep == b.CurrentStep && b.CurrentStep > 0)
+}
+
+// routeKind is what the send path does with this advance.
+type routeKind int
+
+const (
+ // routeSend: send target now.
+ routeSend routeKind = iota
+ // routeWait: nothing to send yet; look again at recheckAt.
+ routeWait
+ // routeEnd: the path has ended; complete the enrollment without a send.
+ routeEnd
+ // routeSkip: the campaign has no step to send at all (the linear path's
+ // ErrNoRows → Skip, preserved).
+ routeSkip
+)
+
+// routeDecision is decideRoute's answer.
+type routeDecision struct {
+ kind routeKind
+ target seqgraph.Step
+ recheckAt time.Time
+ // lastStep and nextDelay describe the route OUT of target once it is sent —
+ // what AdvanceStepCursor schedules next. They replace the linear path's
+ // "is there a step after this one" lookup.
+ lastStep bool
+ nextDelay int
+}
+
+// routeInput is everything decideRoute reads. It is a value, and evidence is a
+// function, so the whole decision is unit-testable without a database.
+type routeInput struct {
+ graph seqgraph.Graph
+ // cursor is sequence_enrollments.current_step: the step_order last sent, 0
+ // before the first send.
+ cursor int32
+ // lastSentAt is when the cursor step was sent — the start of its window and
+ // the reference point for the successor's delay.
+ lastSentAt time.Time
+ now time.Time
+ window cadence.Window
+ // key seeds the window's humanization (the enrollment id), as it does for
+ // every other due-time computation on this path.
+ key string
+}
+
+// evidenceFunc returns the earliest event of the branch's signal for the cursor
+// step inside [start, start+window], or the zero time when there is none.
+type evidenceFunc func(cursor seqgraph.Step, b seqgraph.Branch, start time.Time) (time.Time, error)
+
+// decideRoute routes one advance. See the package-level notes above for the
+// rules; the order of the checks is:
+//
+// 1. No send yet → the entry step, now (the launch stagger scheduled it).
+// 2. The route out of the cursor step: end, a fixed next step, or a condition.
+// 3. A condition still open → wait until it can next change (bounded).
+// 4. A decided route → wait until the target's delay has elapsed from the
+// decision AND the campaign's window is open; then send.
+func decideRoute(in routeInput, evidence evidenceFunc) (routeDecision, error) {
+ if in.cursor == 0 {
+ entry, ok := in.graph.Entry()
+ if !ok {
+ return routeDecision{kind: routeSkip}, nil
+ }
+ return in.sendDecision(entry), nil
+ }
+ if in.lastSentAt.IsZero() {
+ // current_step > 0 is only ever written together with last_sent_at, so
+ // this is a corrupted row. Failing loudly beats inventing a window start:
+ // a start of "now" would re-open the window on every advance and the
+ // condition would never close.
+ return routeDecision{}, fmt.Errorf("enrollment %s is past step %d but has no last_sent_at", in.key, in.cursor)
+ }
+ cursor, ok := in.graph.StepByOrder(in.cursor)
+ if !ok {
+ // The step this contact last received no longer exists. Step deletes are
+ // draft-only, so a running campaign cannot get here; ending the path is
+ // the answer that cannot send the wrong message.
+ return routeDecision{kind: routeEnd}, nil
+ }
+
+ plan := in.graph.After(cursor.ID)
+ decidedAt := in.lastSentAt
+ var target seqgraph.Step
+ switch plan.Kind {
+ case seqgraph.PlanEnd:
+ return routeDecision{kind: routeEnd}, nil
+ case seqgraph.PlanNext:
+ target = plan.Next
+ case seqgraph.PlanAwait:
+ first, err := evidence(cursor, plan.Branch, in.lastSentAt)
+ if err != nil {
+ return routeDecision{}, err
+ }
+ verdict := seqgraph.Evaluate(plan.Branch.Condition, in.lastSentAt, plan.Branch.Window(), first, in.now)
+ if !verdict.Decided {
+ // Look again when the answer can next change: the deadline, or the
+ // recheck interval if that comes first.
+ return routeDecision{kind: routeWait, recheckAt: in.snap(minTime(verdict.At, in.now.Add(conditionRecheckInterval)))}, nil
+ }
+ next, ok := in.graph.Step(plan.Branch.Target(verdict))
+ if !ok {
+ return routeDecision{kind: routeEnd}, nil
+ }
+ target, decidedAt = next, verdict.At
+ }
+
+ due := decidedAt.Add(time.Duration(target.DelaySeconds) * time.Second)
+ if in.now.Before(due.Add(-routeDueTolerance)) || !in.window.Contains(in.now) {
+ return routeDecision{kind: routeWait, recheckAt: in.snap(maxTime(due, in.now))}, nil
+ }
+ return in.sendDecision(target), nil
+}
+
+// sendDecision is a routeSend for target, with the route out of it resolved.
+func (in routeInput) sendDecision(target seqgraph.Step) routeDecision {
+ d := routeDecision{kind: routeSend, target: target}
+ switch plan := in.graph.After(target.ID); plan.Kind {
+ case seqgraph.PlanEnd:
+ d.lastStep = true
+ case seqgraph.PlanNext:
+ d.nextDelay = int(plan.Next.DelaySeconds)
+ case seqgraph.PlanAwait:
+ // The first look at the condition: the deadline, or one recheck interval
+ // after the send if that is sooner.
+ d.nextDelay = int(min(plan.Branch.Window(), conditionRecheckInterval) / time.Second)
+ }
+ return d
+}
+
+// snap moves t into the campaign's send window. A wait must END inside the
+// window: the advance that wakes up may send immediately, and nothing later on
+// this path re-checks the window.
+func (in routeInput) snap(t time.Time) time.Time {
+ if at, err := in.window.Next(t, in.key); err == nil {
+ return at
+ }
+ return t
+}
+
+func maxTime(a, b time.Time) time.Time {
+ if a.After(b) {
+ return a
+ }
+ return b
+}
+
+func minTime(a, b time.Time) time.Time {
+ if a.Before(b) {
+ return a
+ }
+ return b
+}
+
+// graphRouteResult is routeGraph's answer: the decision plus the full step row
+// to send when there is one.
+type graphRouteResult struct {
+ routeDecision
+ step gen.SequenceStep
+}
+
+// routeGraph loads the campaign's graph and routes one advance through it. It
+// applies the database-backed half of the rules decideRoute cannot see: the
+// engagement evidence and the loop backstop.
+func (c client) routeGraph(ctx context.Context, ws uuid.UUID, enrollmentID string, b gen.GetStepEnrollmentBundleRow,
+ branches []gen.SequenceStepBranch, sched cadence.Schedule, now time.Time) (graphRouteResult, error) {
+ steps, err := c.q.ListStepsByCampaign(ctx, gen.ListStepsByCampaignParams{CampaignID: b.CampaignID, WorkspaceID: ws})
+ if err != nil {
+ return graphRouteResult{}, fmt.Errorf("list steps: %w", err)
+ }
+ win, err := sched.Compile()
+ if err != nil {
+ return graphRouteResult{}, fmt.Errorf("enrollment %s route: %w", enrollmentID, err)
+ }
+ g := sequencestep.BuildGraph(steps, branches)
+ d, err := decideRoute(routeInput{
+ graph: g, cursor: b.CurrentStep, lastSentAt: b.LastSentAt.Time, now: now, window: win, key: enrollmentID,
+ }, func(cursor seqgraph.Step, br seqgraph.Branch, start time.Time) (time.Time, error) {
+ return c.firstEvidence(ctx, ws, b, cursor, br, start)
+ })
+ if err != nil {
+ return graphRouteResult{}, err
+ }
+ if d.kind != routeSend {
+ return graphRouteResult{routeDecision: d}, nil
+ }
+ if b.CurrentStep > 0 && g.HasBranches() {
+ revisit, err := c.revisits(ctx, ws, b, b.CurrentStep, d.target)
+ if err != nil {
+ return graphRouteResult{}, err
+ }
+ if revisit {
+ // The save path refuses every loop, under a lock, so this is a
+ // backstop: without it a loop that got in anyway would recover-forward
+ // through ClaimAlreadySent step after step, forever, never sending
+ // and never finishing. End the path loudly instead.
+ slog.WarnContext(ctx, "sequence_graph_loop_detected",
+ "enrollment_id", enrollmentID, "campaign_id", b.CampaignID, "step_order", d.target.Order)
+ return graphRouteResult{routeDecision: routeDecision{kind: routeEnd}}, nil
+ }
+ }
+ for _, st := range steps {
+ if st.ID == d.target.ID {
+ return graphRouteResult{routeDecision: d, step: st}, nil
+ }
+ }
+ // Unreachable: target came from this same step list.
+ return graphRouteResult{}, fmt.Errorf("routed step %s vanished", d.target.ID)
+}
+
+// revisits reports whether target was already sent EARLIER on this contact's
+// path. Its deterministic send row existing is not enough — that is also the
+// recover-forward case, where target was sent but the cursor advance to it did
+// not commit — so the test compares it with the CURRENT step's own send row: a
+// recover-forward row for target was created after the current step's, a loop's
+// row before it (the loop visited target first).
+//
+// Both instants are sends.created_at, the same column stamped by the same
+// database clock, so there is no skew to absorb and no tolerance — a tolerance
+// would blind this to the loop that matters most, zero-delay steps seconds
+// apart. (enrollment.last_sent_at would be the wrong reference: a recover-forward
+// re-stamps it, so it can land after a genuinely-earlier visit.)
+func (c client) revisits(ctx context.Context, ws uuid.UUID, b gen.GetStepEnrollmentBundleRow, cursorOrder int32, target seqgraph.Step) (bool, error) {
+ targetAt, found, err := c.stepSendCreatedAt(ctx, ws, b, target.Order)
+ if err != nil || !found {
+ return false, err
+ }
+ cursorAt, found, err := c.stepSendCreatedAt(ctx, ws, b, cursorOrder)
+ if err != nil || !found {
+ // No row for the step the contact is on cannot happen past step 0 (the
+ // cursor only moves after a claim); with nothing to compare against,
+ // the claim remains the delivery guard.
+ return false, err
+ }
+ return targetAt.Before(cursorAt), nil
+}
+
+// stepSendCreatedAt reads when one step's deterministic send row was created;
+// found=false when the row does not exist.
+func (c client) stepSendCreatedAt(ctx context.Context, ws uuid.UUID, b gen.GetStepEnrollmentBundleRow, order int32) (time.Time, bool, error) {
+ created, err := c.q.StepSendCreatedAt(ctx, gen.StepSendCreatedAtParams{
+ ID: deriveStepSendID(b.CampaignID, b.ContactID, int(order)), WorkspaceID: ws,
+ })
+ if errors.Is(err, pgx.ErrNoRows) {
+ return time.Time{}, false, nil
+ }
+ if err != nil {
+ return time.Time{}, false, fmt.Errorf("step send lookup: %w", err)
+ }
+ return created.Time, true, nil
+}
+
+// firstEvidence reads the earliest qualifying event for one condition.
+//
+// Opens and clicks are keyed on the cursor step's OWN deterministic send id, so
+// they are "opened THIS step", never an earlier one; they count HUMAN events
+// only (invariant 84). Replies span both legs of the conversation: the step went
+// out as a sends row, but the answer lives in inbox_messages, matched on the
+// enrollment's campaign and contact rather than on any one send.
+func (c client) firstEvidence(ctx context.Context, ws uuid.UUID, b gen.GetStepEnrollmentBundleRow,
+ cursor seqgraph.Step, br seqgraph.Branch, start time.Time) (time.Time, error) {
+ end := start.Add(br.Window())
+ var (
+ at pgtype.Timestamptz
+ err error
+ )
+ switch br.Condition.Signal() {
+ case seqgraph.SignalOpen, seqgraph.SignalClick:
+ kind := gen.TrackingEventKindOpen
+ if br.Condition.Signal() == seqgraph.SignalClick {
+ kind = gen.TrackingEventKindClick
+ }
+ at, err = c.q.FirstHumanTrackingEventAt(ctx, gen.FirstHumanTrackingEventAtParams{
+ SendID: deriveStepSendID(b.CampaignID, b.ContactID, int(cursor.Order)),
+ WorkspaceID: ws, Kind: kind, WindowEnd: pgtype.Timestamptz{Time: end, Valid: true},
+ })
+ case seqgraph.SignalReply:
+ at, err = c.q.FirstInboundReplyAt(ctx, gen.FirstInboundReplyAtParams{
+ WorkspaceID: ws,
+ CampaignID: pgtype.UUID{Bytes: b.CampaignID, Valid: true},
+ ContactID: pgtype.UUID{Bytes: b.ContactID, Valid: true},
+ WindowStart: pgtype.Timestamptz{Time: start, Valid: true},
+ WindowEnd: pgtype.Timestamptz{Time: end, Valid: true},
+ LabelKey: br.ReplyLabelKey,
+ })
+ default:
+ return time.Time{}, fmt.Errorf("condition %q has no evidence to read", br.Condition)
+ }
+ if errors.Is(err, pgx.ErrNoRows) {
+ return time.Time{}, nil
+ }
+ if err != nil {
+ return time.Time{}, fmt.Errorf("read %s evidence: %w", br.Condition, err)
+ }
+ return at.Time, nil
+}
+
+// routeStep loads the schedule the route needs (a wait must end inside the send
+// window) and routes the advance.
+func (c client) routeStep(ctx context.Context, ws uuid.UUID, enrollmentID string, b gen.GetStepEnrollmentBundleRow,
+ branches []gen.SequenceStepBranch) (graphRouteResult, error) {
+ sched, err := c.loadSchedule(ctx, ws, b.CampaignID, b.Timezone)
+ if err != nil {
+ return graphRouteResult{}, err
+ }
+ return c.routeGraph(ctx, ws, enrollmentID, b, branches, sched, time.Now())
+}
+
+// applyRoute carries out a route that sends nothing this advance, and returns
+// the job the worker acts on.
+//
+// These are control-plane writes made while building a job, which the linear
+// path does not do. They belong here rather than behind new worker-invoked
+// coreapi methods because they are DECISIONS about committed data, not outcomes
+// of a delivery: both are idempotent and guarded on status='active', a retried
+// or raced advance recomputes the identical decision (the verdict cannot change
+// once taken), and the remote transport serves this very function in the
+// control plane, so a worker never needs a way to express either write.
+func (c client) applyRoute(ctx context.Context, ws, eid uuid.UUID, enrollmentID string,
+ b gen.GetStepEnrollmentBundleRow, d routeDecision) (coreapi.StepSendJob, error) {
+ switch d.kind {
+ case routeEnd:
+ if err := c.enroll.FinishRoute(ctx, ws, eid); err != nil {
+ return coreapi.StepSendJob{}, fmt.Errorf("finish routed enrollment: %w", err)
+ }
+ return coreapi.StepSendJob{Skip: true}, nil
+ case routeWait:
+ // Never pull a due time EARLIER than one already stamped: a later
+ // next_due_at is an out-of-office deferral, and waking inside the stated
+ // absence would route — and possibly send — into it.
+ recheck := d.recheckAt
+ if b.NextDueAt.Valid && b.NextDueAt.Time.After(recheck) {
+ recheck = b.NextDueAt.Time
+ }
+ if err := c.enroll.AwaitCondition(ctx, ws, eid, recheck); err != nil {
+ return coreapi.StepSendJob{}, fmt.Errorf("await branch condition: %w", err)
+ }
+ return coreapi.StepSendJob{
+ EnrollmentID: enrollmentID, WorkspaceID: ws.String(),
+ ConditionPending: true, RecheckAt: recheck,
+ // Skip rides along so a worker built before ConditionPending existed
+ // treats this job as inert instead of claiming a send with an empty
+ // job; the next_due_at stamped above lets the sweeper re-drive it.
+ Skip: true,
+ }, nil
+ default:
+ return coreapi.StepSendJob{Skip: true}, nil
+ }
+}
diff --git a/internal/coreapi/inprocess/branchroute_test.go b/internal/coreapi/inprocess/branchroute_test.go
new file mode 100644
index 00000000..fdf4d094
--- /dev/null
+++ b/internal/coreapi/inprocess/branchroute_test.go
@@ -0,0 +1,303 @@
+package inprocess
+
+import (
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/inroad/inroad/internal/platform/cadence"
+ "github.com/inroad/inroad/internal/platform/db/gen"
+ "github.com/inroad/inroad/internal/platform/seqgraph"
+)
+
+// alwaysOpen is a window that never blocks, so a test isolates the rule it is
+// about from send-window snapping.
+func alwaysOpen(t *testing.T) cadence.Window {
+ t.Helper()
+ var ws []cadence.SendWindow
+ for d := range 7 {
+ ws = append(ws, cadence.SendWindow{Weekday: d, StartMinute: 0, EndMinute: 24 * 60})
+ }
+ w, err := cadence.Schedule{Timezone: "UTC", Windows: ws}.Compile()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return w
+}
+
+// businessHours is Mon–Fri 09:00–17:00 UTC.
+func businessHours(t *testing.T) cadence.Window {
+ t.Helper()
+ w, err := cadence.DefaultSchedule("UTC").Compile()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return w
+}
+
+// routeSteps is a three-step campaign; step 3 has a 1h delay, step 2 a 2h one.
+func routeSteps() (ids [3]uuid.UUID, steps []seqgraph.Step) {
+ for i := range ids {
+ ids[i] = uuid.New()
+ }
+ return ids, []seqgraph.Step{
+ {ID: ids[0], Order: 1},
+ {ID: ids[1], Order: 2, DelaySeconds: 7200},
+ {ID: ids[2], Order: 3, DelaySeconds: 3600},
+ }
+}
+
+// noEvidence is an evidence source that has seen nothing.
+func noEvidence(seqgraph.Step, seqgraph.Branch, time.Time) (time.Time, error) {
+ return time.Time{}, nil
+}
+
+// eventAt is an evidence source that saw the signal at t.
+func eventAt(t time.Time) evidenceFunc {
+ return func(seqgraph.Step, seqgraph.Branch, time.Time) (time.Time, error) { return t, nil }
+}
+
+// A Wednesday, mid-morning UTC — inside businessHours.
+var routeSentAt = time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC)
+
+func TestDecideRouteEntryStepSendsNow(t *testing.T) {
+ ids, steps := routeSteps()
+ g := seqgraph.New(steps, []seqgraph.Branch{{StepID: ids[0], Condition: seqgraph.Opened, WithinDays: 2, Yes: ids[2], No: ids[1]}})
+ d, err := decideRoute(routeInput{graph: g, cursor: 0, now: routeSentAt, window: alwaysOpen(t), key: "e"}, noEvidence)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if d.kind != routeSend || d.target.ID != ids[0] {
+ t.Fatalf("entry = %+v", d)
+ }
+ // The first look at step 1's condition is one recheck interval out (the
+ // 2-day window is longer), and the enrollment is not on its last step.
+ if d.lastStep || d.nextDelay != int(conditionRecheckInterval/time.Second) {
+ t.Fatalf("after step 1: lastStep=%v nextDelay=%d", d.lastStep, d.nextDelay)
+ }
+}
+
+func TestDecideRouteNoStepsSkips(t *testing.T) {
+ d, err := decideRoute(routeInput{graph: seqgraph.New(nil, nil), now: routeSentAt, window: alwaysOpen(t)}, noEvidence)
+ if err != nil || d.kind != routeSkip {
+ t.Fatalf("empty campaign = %+v, %v", d, err)
+ }
+}
+
+func TestDecideRouteConditionPendingWaitsBoundedByRecheck(t *testing.T) {
+ ids, steps := routeSteps()
+ g := seqgraph.New(steps, []seqgraph.Branch{{StepID: ids[0], Condition: seqgraph.Opened, WithinDays: 2, Yes: ids[2], No: ids[1]}})
+ now := routeSentAt.Add(10 * time.Minute)
+ d, err := decideRoute(routeInput{graph: g, cursor: 1, lastSentAt: routeSentAt, now: now, window: alwaysOpen(t), key: "e"}, noEvidence)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if d.kind != routeWait {
+ t.Fatalf("want wait, got %+v", d)
+ }
+ // Snapped into the window, which only ever moves an instant later, by under
+ // two minutes of humanization.
+ want := now.Add(conditionRecheckInterval)
+ if d.recheckAt.Before(want) || d.recheckAt.After(want.Add(2*time.Minute)) {
+ t.Fatalf("recheck = %v, want ~%v", d.recheckAt, want)
+ }
+}
+
+// Near the deadline the recheck is the deadline, not a full interval later.
+func TestDecideRouteConditionPendingRecheckStopsAtDeadline(t *testing.T) {
+ ids, steps := routeSteps()
+ g := seqgraph.New(steps, []seqgraph.Branch{{StepID: ids[0], Condition: seqgraph.Replied, WithinDays: 1, Yes: ids[2], No: ids[1]}})
+ deadline := routeSentAt.Add(24 * time.Hour)
+ now := deadline.Add(-10 * time.Minute)
+ d, err := decideRoute(routeInput{graph: g, cursor: 1, lastSentAt: routeSentAt, now: now, window: alwaysOpen(t), key: "e"}, noEvidence)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if d.kind != routeWait || d.recheckAt.Before(deadline) || d.recheckAt.After(deadline.Add(2*time.Minute)) {
+ t.Fatalf("recheck = %+v, want ~deadline %v", d, deadline)
+ }
+}
+
+// The positive event routes YES as soon as it is seen, and the target's delay
+// counts from the event — not from the send, and not from when we noticed.
+func TestDecideRouteEarlyEventRoutesYesWithDelayFromEvent(t *testing.T) {
+ ids, steps := routeSteps()
+ g := seqgraph.New(steps, []seqgraph.Branch{{StepID: ids[0], Condition: seqgraph.Opened, WithinDays: 3, Yes: ids[2], No: ids[1]}})
+ opened := routeSentAt.Add(30 * time.Minute)
+ in := routeInput{graph: g, cursor: 1, lastSentAt: routeSentAt, window: alwaysOpen(t), key: "e"}
+
+ // 40 minutes after the open: step 3's 1h delay has not elapsed yet.
+ in.now = opened.Add(40 * time.Minute)
+ d, err := decideRoute(in, eventAt(opened))
+ if err != nil {
+ t.Fatal(err)
+ }
+ due := opened.Add(time.Hour)
+ if d.kind != routeWait || d.recheckAt.Before(due) || d.recheckAt.After(due.Add(2*time.Minute)) {
+ t.Fatalf("before the yes step is due: %+v, want wait until ~%v", d, due)
+ }
+
+ // Once due: send step 3 (the YES exit). Step 3 has no branch and is last.
+ in.now = due.Add(time.Minute)
+ d, err = decideRoute(in, eventAt(opened))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if d.kind != routeSend || d.target.ID != ids[2] || !d.lastStep {
+ t.Fatalf("yes route = %+v", d)
+ }
+}
+
+func TestDecideRouteWindowCloseRoutesNo(t *testing.T) {
+ ids, steps := routeSteps()
+ g := seqgraph.New(steps, []seqgraph.Branch{{StepID: ids[0], Condition: seqgraph.Clicked, WithinDays: 1, Yes: ids[2], No: ids[1]}})
+ // Step 2's delay (2h) counts from the deadline.
+ now := routeSentAt.Add(24*time.Hour + 2*time.Hour + time.Minute)
+ d, err := decideRoute(routeInput{graph: g, cursor: 1, lastSentAt: routeSentAt, now: now, window: alwaysOpen(t), key: "e"}, noEvidence)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if d.kind != routeSend || d.target.ID != ids[1] {
+ t.Fatalf("no route = %+v", d)
+ }
+ // Step 2 has no branch: it falls through to step 3 with step 3's delay.
+ if d.lastStep || d.nextDelay != 3600 {
+ t.Fatalf("after step 2: lastStep=%v nextDelay=%d", d.lastStep, d.nextDelay)
+ }
+}
+
+// A negated condition is decided NO by the event, immediately.
+func TestDecideRouteNotRepliedDecidedByReply(t *testing.T) {
+ ids, steps := routeSteps()
+ g := seqgraph.New(steps, []seqgraph.Branch{{StepID: ids[0], Condition: seqgraph.NotReplied, WithinDays: 5, Yes: ids[1]}})
+ replied := routeSentAt.Add(time.Hour)
+ d, err := decideRoute(routeInput{graph: g, cursor: 1, lastSentAt: routeSentAt, now: replied.Add(time.Minute),
+ window: alwaysOpen(t), key: "e"}, eventAt(replied))
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The NO exit is unset: the path ends, without waiting out the 5 days.
+ if d.kind != routeEnd {
+ t.Fatalf("replied on a not_replied branch with no NO exit = %+v", d)
+ }
+}
+
+func TestDecideRouteAlwaysToEndFinishes(t *testing.T) {
+ ids, steps := routeSteps()
+ g := seqgraph.New(steps, []seqgraph.Branch{{StepID: ids[1], Condition: seqgraph.Always}})
+ d, err := decideRoute(routeInput{graph: g, cursor: 2, lastSentAt: routeSentAt, now: routeSentAt.Add(time.Hour),
+ window: alwaysOpen(t), key: "e"}, noEvidence)
+ if err != nil || d.kind != routeEnd {
+ t.Fatalf("always -> end = %+v, %v", d, err)
+ }
+}
+
+// A decided route may not send outside the campaign's window: it waits for the
+// window to open, even though the target is overdue.
+func TestDecideRouteWaitsForWindow(t *testing.T) {
+ ids, steps := routeSteps()
+ g := seqgraph.New(steps, []seqgraph.Branch{{StepID: ids[0], Condition: seqgraph.Opened, WithinDays: 1, Yes: ids[2], No: ids[1]}})
+ opened := routeSentAt.Add(time.Hour)
+ saturday := time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)
+ d, err := decideRoute(routeInput{graph: g, cursor: 1, lastSentAt: routeSentAt, now: saturday,
+ window: businessHours(t), key: "e"}, eventAt(opened))
+ if err != nil {
+ t.Fatal(err)
+ }
+ monday := time.Date(2026, 9, 28, 9, 0, 0, 0, time.UTC)
+ if d.kind != routeWait || d.recheckAt.Before(monday) || d.recheckAt.After(monday.Add(2*time.Minute)) {
+ t.Fatalf("weekend route = %+v, want wait until ~%v", d, monday)
+ }
+}
+
+// The mid-flight rule for a DELETED branch: the enrollment falls through to the
+// linear successor, which still honours its own delay from the last send rather
+// than going out at the recheck time the condition had scheduled.
+func TestDecideRouteBranchRemovedMidWaitHonoursDelay(t *testing.T) {
+ _, steps := routeSteps()
+ g := seqgraph.New(steps, nil) // the only branch was deleted
+ in := routeInput{graph: g, cursor: 1, lastSentAt: routeSentAt, window: alwaysOpen(t), key: "e"}
+
+ in.now = routeSentAt.Add(time.Hour) // a recheck the old condition scheduled
+ d, err := decideRoute(in, noEvidence)
+ if err != nil {
+ t.Fatal(err)
+ }
+ due := routeSentAt.Add(2 * time.Hour) // step 2's delay
+ if d.kind != routeWait || d.recheckAt.Before(due) {
+ t.Fatalf("fall-through before its delay = %+v, want wait until %v", d, due)
+ }
+
+ in.now = due
+ if d, err = decideRoute(in, noEvidence); err != nil || d.kind != routeSend || d.target.Order != 2 {
+ t.Fatalf("fall-through once due = %+v, %v", d, err)
+ }
+}
+
+// An advance that fires on schedule is never bounced by the few seconds of skew
+// between the worker's clock and the database's last_sent_at.
+func TestDecideRouteToleratesClockSkew(t *testing.T) {
+ ids, steps := routeSteps()
+ g := seqgraph.New(steps, []seqgraph.Branch{{StepID: ids[0], Condition: seqgraph.Always, Yes: ids[1]}})
+ due := routeSentAt.Add(2 * time.Hour)
+ d, err := decideRoute(routeInput{graph: g, cursor: 1, lastSentAt: routeSentAt, now: due.Add(-5 * time.Second),
+ window: alwaysOpen(t), key: "e"}, noEvidence)
+ if err != nil || d.kind != routeSend {
+ t.Fatalf("5s early = %+v, %v", d, err)
+ }
+}
+
+func TestDecideRouteDeletedCursorStepEnds(t *testing.T) {
+ _, steps := routeSteps()
+ g := seqgraph.New(steps, nil)
+ d, err := decideRoute(routeInput{graph: g, cursor: 9, lastSentAt: routeSentAt, now: routeSentAt, window: alwaysOpen(t)}, noEvidence)
+ if err != nil || d.kind != routeEnd {
+ t.Fatalf("cursor on a vanished step = %+v, %v", d, err)
+ }
+}
+
+func TestDecideRouteMissingLastSentIsAnError(t *testing.T) {
+ _, steps := routeSteps()
+ _, err := decideRoute(routeInput{graph: seqgraph.New(steps, nil), cursor: 1, now: routeSentAt, window: alwaysOpen(t)}, noEvidence)
+ if err == nil {
+ t.Fatal("a cursor past step 0 with no last_sent_at must fail loudly")
+ }
+}
+
+func TestDecideRouteEvidenceErrorPropagates(t *testing.T) {
+ ids, steps := routeSteps()
+ g := seqgraph.New(steps, []seqgraph.Branch{{StepID: ids[0], Condition: seqgraph.Opened, WithinDays: 1}})
+ boom := errors.New("db down")
+ _, err := decideRoute(routeInput{graph: g, cursor: 1, lastSentAt: routeSentAt, now: routeSentAt, window: alwaysOpen(t)},
+ func(seqgraph.Step, seqgraph.Branch, time.Time) (time.Time, error) { return time.Time{}, boom })
+ if !errors.Is(err, boom) {
+ t.Fatalf("evidence error = %v, want %v", err, boom)
+ }
+}
+
+// Linear campaigns never reach the graph router: no branches and no condition
+// wait means usesGraphRouting is false and localStepSendJob runs the original
+// GetNextStep path. That is the whole of the "linear campaigns are unchanged"
+// guarantee on the send path, so it is pinned here explicitly.
+func TestUsesGraphRoutingOnlyForBranchedOrParkedEnrollments(t *testing.T) {
+ two, three := int32(2), int32(3)
+ cases := []struct {
+ name string
+ branches []gen.SequenceStepBranch
+ bundle gen.GetStepEnrollmentBundleRow
+ want bool
+ }{
+ {"linear, never waited", nil, gen.GetStepEnrollmentBundleRow{CurrentStep: 2}, false},
+ {"linear, fresh enrollment", nil, gen.GetStepEnrollmentBundleRow{CurrentStep: 0}, false},
+ {"linear, waited at an EARLIER step", nil, gen.GetStepEnrollmentBundleRow{CurrentStep: 3, AwaitingConditionStep: &two}, false},
+ {"branch deleted while waiting here", nil, gen.GetStepEnrollmentBundleRow{CurrentStep: 3, AwaitingConditionStep: &three}, true},
+ {"campaign has a branch", []gen.SequenceStepBranch{{Condition: "always"}}, gen.GetStepEnrollmentBundleRow{}, true},
+ }
+ for _, tc := range cases {
+ if got := usesGraphRouting(tc.branches, tc.bundle); got != tc.want {
+ t.Errorf("%s: usesGraphRouting = %v, want %v", tc.name, got, tc.want)
+ }
+ }
+}
diff --git a/internal/coreapi/inprocess/inboxpoll.go b/internal/coreapi/inprocess/inboxpoll.go
index 271dcb7a..617112ff 100644
--- a/internal/coreapi/inprocess/inboxpoll.go
+++ b/internal/coreapi/inprocess/inboxpoll.go
@@ -191,7 +191,18 @@ func (c client) localRecordReplyClass(ctx context.Context, enrollmentID, workspa
if err != nil {
return err
}
- return c.recordReplyClass(ctx, eid, ws, class, source, confidence)
+ if err := c.recordReplyClass(ctx, eid, ws, class, source, confidence); err != nil {
+ return err
+ }
+ // A reply that did not stop the sequence can still decide a branch
+ // condition on the step the contact is waiting at ("replied within N
+ // days"). Pull that enrollment's due time to now so the next sweep routes it
+ // instead of leaving it for the next hourly recheck. A no-op for every
+ // enrollment not waiting on a reply condition, and for automated replies,
+ // which are the ones that defer an enrollment (see the query).
+ return c.q.NudgeEnrollmentAwaitingReply(ctx, gen.NudgeEnrollmentAwaitingReplyParams{
+ ID: eid, WorkspaceID: ws, ReplyClass: class,
+ })
}
// MarkUnsubscribed suppresses the address and, when the reply belongs to an
diff --git a/internal/coreapi/inprocess/stepsendjob.go b/internal/coreapi/inprocess/stepsendjob.go
index 931f112a..f8220d25 100644
--- a/internal/coreapi/inprocess/stepsendjob.go
+++ b/internal/coreapi/inprocess/stepsendjob.go
@@ -138,11 +138,13 @@ func decodeCustom(b []byte) map[string]string {
// threading computes the In-Reply-To / References headers for the step about to
// send. Empty for step 1. For later steps it prefers the immediately-preceding
// sent message (proper chain), falling back to the stored thread root.
-func (c client) threading(ctx context.Context, order int, campaignID, contactID uuid.UUID, threadRootID string) (inReplyTo, references string) {
+func (c client) threading(ctx context.Context, ws uuid.UUID, order int, campaignID, contactID uuid.UUID, threadRootID string) (inReplyTo, references string) {
if order <= 1 {
return "", ""
}
- prior, err := c.q.LatestSentForContact(ctx, gen.LatestSentForContactParams{CampaignID: campaignID, ContactID: contactID})
+ prior, err := c.q.LatestSentForContact(ctx, gen.LatestSentForContactParams{
+ CampaignID: campaignID, ContactID: contactID, WorkspaceID: ws,
+ })
if err == nil && prior.MessageID != "" {
return prior.MessageID, strings.TrimSpace(prior.ReferencesHeader + " " + prior.MessageID)
}
@@ -196,7 +198,12 @@ func (c client) newLeadLimitReached(ctx context.Context, ws, campaignID uuid.UUI
}
// localStepSendJob resolves the enrollment's next due step and builds the send
-// job. Read-only: creates no rows. workspaceID is pinned in the SQL WHERE
+// job. It creates no sends row — the claim does that — so a suppressed or
+// capped step leaves no orphan. It is not strictly read-only, though: resolving
+// a sender pins a pool mailbox to the enrollment, and on a branched campaign
+// routing may complete the enrollment (a path that ends) or park it until a
+// condition can next change (see applyRoute). Every such write is guarded on
+// status='active' and idempotent. workspaceID is pinned in the SQL WHERE
// (defense in depth on the unguessable enrollment UUID).
func (c client) localStepSendJob(ctx context.Context, enrollmentID, workspaceID string) (coreapi.StepSendJob, error) {
eid, err := uuid.Parse(enrollmentID)
@@ -231,17 +238,47 @@ func (c client) localStepSendJob(ctx context.Context, enrollmentID, workspaceID
}, nil
}
- // Resolve the next step by order rather than current_step+1: DeleteStep does
- // not renumber, so orders can have gaps (e.g. {1,3}). GetNextStep skips gaps;
- // ErrNoRows means the cursor is at/after the last step → done.
- step, err := c.q.GetNextStep(ctx, gen.GetNextStepParams{
- CampaignID: b.CampaignID, WorkspaceID: ws, StepOrder: b.CurrentStep,
- })
- if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- return coreapi.StepSendJob{Skip: true}, nil
+ // A campaign with branches (or an enrollment still parked by one) is routed
+ // through the graph; every other campaign takes the linear path below exactly
+ // as it did before branching existed. See branchroute.go.
+ branches, err := c.q.ListBranchesByCampaign(ctx, gen.ListBranchesByCampaignParams{CampaignID: b.CampaignID, WorkspaceID: ws})
+ if err != nil {
+ return coreapi.StepSendJob{}, fmt.Errorf("list branches: %w", err)
+ }
+ var (
+ step gen.SequenceStep
+ route *routeDecision
+ )
+ if usesGraphRouting(branches, b) {
+ // A campaign that is not running must not have its enrollments moved at
+ // all — not finished by a path that ends, nor parked by a condition —
+ // only held, exactly as the linear path holds them below. So on the graph
+ // path the gate comes BEFORE routing. (On the linear path it stays after
+ // GetNextStep, where it has always been, so linear behaviour is unchanged.)
+ if b.CampaignStatus != string(campaign.StatusRunning) {
+ return c.campaignPausedJob(ctx, ws, enrollmentID, b)
+ }
+ routed, err := c.routeStep(ctx, ws, enrollmentID, b, branches)
+ if err != nil {
+ return coreapi.StepSendJob{}, err
+ }
+ if routed.kind != routeSend {
+ return c.applyRoute(ctx, ws, eid, enrollmentID, b, routed.routeDecision)
+ }
+ step, route = routed.step, &routed.routeDecision
+ } else {
+ // Resolve the next step by order rather than current_step+1: DeleteStep
+ // does not renumber, so orders can have gaps (e.g. {1,3}). GetNextStep
+ // skips gaps; ErrNoRows means the cursor is at/after the last step → done.
+ step, err = c.q.GetNextStep(ctx, gen.GetNextStepParams{
+ CampaignID: b.CampaignID, WorkspaceID: ws, StepOrder: b.CurrentStep,
+ })
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return coreapi.StepSendJob{Skip: true}, nil
+ }
+ return coreapi.StepSendJob{}, err
}
- return coreapi.StepSendJob{}, err
}
nextOrder := int(step.StepOrder)
@@ -259,17 +296,7 @@ func (c client) localStepSendJob(ctx context.Context, enrollmentID, workspaceID
// comes FIRST because it is already in the bundle, so it costs no query, while
// campaignLimitReached runs a COUNT.
if b.CampaignStatus != string(campaign.StatusRunning) {
- // The schedule travels for the same reason as the daily-limit branch: a
- // deferred retry SENDS as soon as it runs, so the worker has to wake inside
- // the campaign's window.
- sched, serr := c.loadSchedule(ctx, ws, b.CampaignID, b.Timezone)
- if serr != nil {
- return coreapi.StepSendJob{}, serr
- }
- return coreapi.StepSendJob{
- EnrollmentID: enrollmentID, WorkspaceID: ws.String(), CampaignPaused: true,
- Schedule: sched,
- }, nil
+ return c.campaignPausedJob(ctx, ws, enrollmentID, b)
}
// The campaign-wide daily limit, stacked on top of the per-mailbox caps: the
@@ -329,17 +356,23 @@ func (c client) localStepSendJob(ctx context.Context, enrollmentID, workspaceID
}
// Is there a step after this one? Its existence decides last-step; its delay
- // is the cadence gap to the following send. One query answers both.
- after, err := c.q.GetNextStep(ctx, gen.GetNextStepParams{
- CampaignID: b.CampaignID, WorkspaceID: ws, StepOrder: step.StepOrder,
- })
- lastStep := errors.Is(err, pgx.ErrNoRows)
- if err != nil && !lastStep {
- return coreapi.StepSendJob{}, err
- }
- nextDelay := 0
- if !lastStep {
- nextDelay = int(after.DelaySeconds)
+ // is the cadence gap to the following send. On the linear path one query
+ // answers both; a routed step already carries the answer from the graph (the
+ // next step, the end, or the first look at its condition).
+ lastStep, nextDelay := false, 0
+ if route != nil {
+ lastStep, nextDelay = route.lastStep, route.nextDelay
+ } else {
+ after, err := c.q.GetNextStep(ctx, gen.GetNextStepParams{
+ CampaignID: b.CampaignID, WorkspaceID: ws, StepOrder: step.StepOrder,
+ })
+ lastStep = errors.Is(err, pgx.ErrNoRows)
+ if err != nil && !lastStep {
+ return coreapi.StepSendJob{}, err
+ }
+ if !lastStep {
+ nextDelay = int(after.DelaySeconds)
+ }
}
// Thread subject is only needed to build "Re: " for a
@@ -362,7 +395,7 @@ func (c client) localStepSendJob(ctx context.Context, enrollmentID, workspaceID
}
}
- inReplyTo, references := c.threading(ctx, nextOrder, b.CampaignID, b.ContactID, b.ThreadRootID)
+ inReplyTo, references := c.threading(ctx, ws, nextOrder, b.CampaignID, b.ContactID, b.ThreadRootID)
// Derived now, before the step is sent, so the worker can embed it in
// tracking tokens at MIME-build time; ClaimStepSend inserts it as the send
@@ -441,6 +474,21 @@ func (c client) localStepSendJob(ctx context.Context, enrollmentID, workspaceID
}, nil
}
+// campaignPausedJob is the job for a campaign that is not running: the worker
+// holds the enrollment and retries later. The schedule travels for the same
+// reason as the daily-limit branch: a deferred retry SENDS as soon as it runs,
+// so the worker has to wake inside the campaign's window.
+func (c client) campaignPausedJob(ctx context.Context, ws uuid.UUID, enrollmentID string, b gen.GetStepEnrollmentBundleRow) (coreapi.StepSendJob, error) {
+ sched, err := c.loadSchedule(ctx, ws, b.CampaignID, b.Timezone)
+ if err != nil {
+ return coreapi.StepSendJob{}, err
+ }
+ return coreapi.StepSendJob{
+ EnrollmentID: enrollmentID, WorkspaceID: ws.String(), CampaignPaused: true,
+ Schedule: sched,
+ }, nil
+}
+
// ClaimStepSend claims one step-send for delivery (claim-before-send). The
// deterministic SendID (derived in GetStepSendJob, embedded in the tracking
// tokens) is the sends row id: a fresh INSERT wins the claim, and only a STALE
diff --git a/internal/coreapi/remote/jobs_test.go b/internal/coreapi/remote/jobs_test.go
index a241c0ea..2ba13e7b 100644
--- a/internal/coreapi/remote/jobs_test.go
+++ b/internal/coreapi/remote/jobs_test.go
@@ -159,6 +159,10 @@ func TestAStepSendJobRoundTripsAndItsCredentialComesFromTheBroker(t *testing.T)
MailboxID: mailbox.String(), SendID: uuid.New().String(), VariantID: uuid.New().String(),
CurrentStep: 2, StepOrder: 3, NextDelaySeconds: 259200, LastStep: true,
Suppressed: false, CampaignLimited: false, NewLeadLimited: false, HealthPaused: false,
+ // Not a combination the control plane builds (a pending job carries no
+ // send), set here so the round trip covers every field on the type.
+ ConditionPending: true,
+ RecheckAt: time.Date(2026, 3, 5, 9, 0, 11, 0, time.UTC),
NotDueUntil: time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC),
EffectiveDailyCap: 50, SentToday: 12, MinIntervalSeconds: 180,
ToEmail: "ada@example.test",
@@ -208,6 +212,32 @@ func TestAStepSendJobRoundTripsAndItsCredentialComesFromTheBroker(t *testing.T)
}
}
+// A branch-condition wait is the shape the control plane actually sends: no
+// mailbox, so no credential is brokered, and ConditionPending/RecheckAt (with
+// the Skip that protects older workers) arrive intact. A field dropped here
+// reaches the worker zero-valued, and a zero RecheckAt would re-drive the
+// enrollment immediately instead of waiting out the condition.
+func TestAConditionPendingJobRoundTripsWithoutACredential(t *testing.T) {
+ ws, enrollment := uuid.New(), uuid.New()
+ jobs := &fakeJobs{step: coreapi.StepSendJob{
+ EnrollmentID: enrollment.String(), WorkspaceID: ws.String(),
+ ConditionPending: true, RecheckAt: time.Date(2026, 9, 24, 14, 3, 7, 0, time.UTC), Skip: true,
+ }}
+ opener := smtpSecret("unused")
+ c, _ := serveJobs(t, jobs, opener)
+
+ got, err := c.GetStepSendJob(context.Background(), enrollment.String(), ws.String())
+ if err != nil {
+ t.Fatalf("GetStepSendJob: %v", err)
+ }
+ if !reflect.DeepEqual(got, jobs.step) {
+ t.Errorf("job mismatch\n got: %+v\nwant: %+v", got, jobs.step)
+ }
+ if opener.calls != 0 {
+ t.Errorf("a job with no mailbox asked the broker %d times", opener.calls)
+ }
+}
+
// The property the whole design rests on, asserted on the BYTES: no job
// response body contains a credential, whatever the control plane's own build
// put on the struct. A hand-written mirror would give this by construction; the
diff --git a/internal/platform/db/branchmigrations_integration_test.go b/internal/platform/db/branchmigrations_integration_test.go
new file mode 100644
index 00000000..0087d0ab
--- /dev/null
+++ b/internal/platform/db/branchmigrations_integration_test.go
@@ -0,0 +1,110 @@
+//go:build integration
+
+package db_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/inroad/inroad/internal/platform/db"
+ "github.com/inroad/inroad/internal/platform/db/dbtest"
+)
+
+// The two conditional-branching migrations, in order.
+const (
+ branchTablesVersion = 20260923110214
+ branchIndexVersion = 20260923144758
+ // preBranchVersion is the migration immediately before the branching tables.
+ preBranchVersion = 20260921111415
+)
+
+// triggerExists reports whether the trigger that makes updated_at the branch's
+// concurrency token (20260923161044) is installed.
+func triggerExists(t *testing.T, ctx context.Context, pool *pgxpool.Pool) bool {
+ t.Helper()
+ var n int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM pg_trigger
+ WHERE tgname = 'sequence_step_branches_advance_updated_at'`).Scan(&n); err != nil {
+ t.Fatalf("trigger lookup: %v", err)
+ }
+ return n == 1
+}
+
+// indexState reports whether the named index exists and, if so, whether it is
+// VALID — a failed CREATE INDEX CONCURRENTLY leaves an invalid index behind
+// rather than rolling back, and IF NOT EXISTS would skip it on every re-run.
+func indexState(t *testing.T, ctx context.Context, pool *pgxpool.Pool, name string) (exists, valid bool) {
+ t.Helper()
+ err := pool.QueryRow(ctx, `
+ SELECT i.indisvalid FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+ WHERE c.relname = $1`, name).Scan(&valid)
+ if err != nil {
+ return false, false
+ }
+ return true, valid
+}
+
+// On a FRESH database, so the concurrent build actually runs rather than being
+// skipped by IF NOT EXISTS: the migration applies (proving the golang-migrate
+// pgx/v5 driver runs a single-statement file outside a transaction block, which
+// CREATE INDEX CONCURRENTLY requires), the index is valid, and both branching
+// migrations round-trip down and up again.
+func TestInboxThreadsCampaignContactIndexIsValid(t *testing.T) {
+ ctx := context.Background()
+ dsn := dbtest.ScratchDSN(t, "branch_index")
+
+ if err := db.Migrate(dsn); err != nil {
+ t.Fatalf("migrate up (fresh): %v", err)
+ }
+ pool, err := db.Connect(ctx, dsn)
+ if err != nil {
+ t.Fatalf("connect: %v", err)
+ }
+ defer pool.Close()
+
+ const idx = "idx_inbox_threads_campaign_contact"
+ if exists, valid := indexState(t, ctx, pool, idx); !exists || !valid {
+ t.Fatalf("%s after a fresh migrate: exists=%v valid=%v", idx, exists, valid)
+ }
+ if !triggerExists(t, ctx, pool) {
+ t.Fatal("updated_at trigger missing after a fresh migrate")
+ }
+
+ // Down past both branching migrations, then up again.
+ if err := db.MigrateTo(dsn, branchIndexVersion); err != nil {
+ t.Fatalf("migrate to %d: %v", branchIndexVersion, err)
+ }
+ if triggerExists(t, ctx, pool) {
+ t.Fatal("updated_at trigger survived its own down migration")
+ }
+ if err := db.MigrateTo(dsn, branchTablesVersion); err != nil {
+ t.Fatalf("roll back the index migration: %v", err)
+ }
+ if exists, _ := indexState(t, ctx, pool, idx); exists {
+ t.Fatalf("%s survived its own down migration", idx)
+ }
+ if err := db.MigrateTo(dsn, preBranchVersion); err != nil {
+ t.Fatalf("roll back the branch tables: %v", err)
+ }
+ var tables int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM pg_class WHERE relname = 'sequence_step_branches'`).Scan(&tables); err != nil || tables != 0 {
+ t.Fatalf("sequence_step_branches after down: count=%d err=%v", tables, err)
+ }
+ var col int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM information_schema.columns
+ WHERE table_name = 'sequence_enrollments' AND column_name = 'awaiting_condition_step'`).Scan(&col); err != nil || col != 0 {
+ t.Fatalf("awaiting_condition_step after down: count=%d err=%v", col, err)
+ }
+
+ if err := db.Migrate(dsn); err != nil {
+ t.Fatalf("migrate up again: %v", err)
+ }
+ if exists, valid := indexState(t, ctx, pool, idx); !exists || !valid {
+ t.Fatalf("%s after up/down/up: exists=%v valid=%v", idx, exists, valid)
+ }
+ if !triggerExists(t, ctx, pool) {
+ t.Fatal("updated_at trigger missing after up/down/up")
+ }
+}
diff --git a/internal/platform/db/export_test.go b/internal/platform/db/export_test.go
new file mode 100644
index 00000000..a3c1142c
--- /dev/null
+++ b/internal/platform/db/export_test.go
@@ -0,0 +1,7 @@
+package db
+
+// Test-only handles on the migration lock's internals, for db_test (which has to
+// be an external package: dbtest imports db).
+const MigrationLockKey = migrationLockKey
+
+var WithMigrationLockCtx = withMigrationLockCtx
diff --git a/internal/platform/db/gen/enrollment.sql.go b/internal/platform/db/gen/enrollment.sql.go
index 65ca8d11..accd88dd 100644
--- a/internal/platform/db/gen/enrollment.sql.go
+++ b/internal/platform/db/gen/enrollment.sql.go
@@ -47,6 +47,28 @@ func (q *Queries) AdvanceEnrollmentStep(ctx context.Context, arg AdvanceEnrollme
return err
}
+const awaitEnrollmentCondition = `-- name: AwaitEnrollmentCondition :exec
+UPDATE sequence_enrollments
+SET next_due_at = $3, awaiting_condition_step = current_step
+WHERE id = $1 AND workspace_id = $2 AND status = 'active'
+`
+
+type AwaitEnrollmentConditionParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ NextDueAt pgtype.Timestamptz `json:"next_due_at"`
+}
+
+// A branch condition on the enrollment's current step is still open (or its
+// routed step is not due yet): record when to look again, and that this wait is
+// governed by a condition at this step (see awaiting_condition_step in migration
+// 20260923110214). Stamping next_due_at keeps the sweeper from re-driving the
+// enrollment every tick while it legitimately waits. Guarded on status='active'.
+func (q *Queries) AwaitEnrollmentCondition(ctx context.Context, arg AwaitEnrollmentConditionParams) error {
+ _, err := q.db.Exec(ctx, awaitEnrollmentCondition, arg.ID, arg.WorkspaceID, arg.NextDueAt)
+ return err
+}
+
const completeEnrollment = `-- name: CompleteEnrollment :exec
UPDATE sequence_enrollments
SET current_step = $3, last_sent_at = now(), status = 'completed',
@@ -197,8 +219,29 @@ func (q *Queries) EnrollListMembers(ctx context.Context, arg EnrollListMembersPa
return items, nil
}
+const finishEnrollment = `-- name: FinishEnrollment :exec
+UPDATE sequence_enrollments
+SET status = 'completed', completed_at = now(), next_due_at = NULL
+WHERE id = $1 AND workspace_id = $2 AND status = 'active'
+`
+
+type FinishEnrollmentParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+// A branch routed this enrollment to the end of its path WITHOUT a send: mark it
+// completed and drop it out of the due index. Unlike CompleteEnrollment it leaves
+// current_step and last_sent_at alone, because nothing was sent — they still
+// describe the last message the contact actually received. Guarded on
+// status='active' for the reason CompleteEnrollment is: a stop is terminal and wins.
+func (q *Queries) FinishEnrollment(ctx context.Context, arg FinishEnrollmentParams) error {
+ _, err := q.db.Exec(ctx, finishEnrollment, arg.ID, arg.WorkspaceID)
+ return err
+}
+
const getEnrollment = `-- name: GetEnrollment :one
-SELECT id, workspace_id, campaign_id, contact_id, current_step, status, stop_reason, enrolled_at, last_sent_at, next_due_at, thread_root_id, completed_at, stopped_at, cap_deferrals, reply_class, reply_source, reply_confidence, replied_at, mailbox_id FROM sequence_enrollments WHERE id = $1 AND workspace_id = $2
+SELECT id, workspace_id, campaign_id, contact_id, current_step, status, stop_reason, enrolled_at, last_sent_at, next_due_at, thread_root_id, completed_at, stopped_at, cap_deferrals, reply_class, reply_source, reply_confidence, replied_at, mailbox_id, awaiting_condition_step FROM sequence_enrollments WHERE id = $1 AND workspace_id = $2
`
type GetEnrollmentParams struct {
@@ -229,6 +272,7 @@ func (q *Queries) GetEnrollment(ctx context.Context, arg GetEnrollmentParams) (S
&i.ReplyConfidence,
&i.RepliedAt,
&i.MailboxID,
+ &i.AwaitingConditionStep,
)
return i, err
}
@@ -353,6 +397,48 @@ func (q *Queries) ListDueEnrollments(ctx context.Context) ([]ListDueEnrollmentsR
return items, nil
}
+const nudgeEnrollmentAwaitingReply = `-- name: NudgeEnrollmentAwaitingReply :exec
+UPDATE sequence_enrollments e
+SET next_due_at = now()
+WHERE e.id = $1 AND e.workspace_id = $2 AND e.status = 'active'
+ AND e.next_due_at > now()
+ AND e.awaiting_condition_step = e.current_step
+ AND NOT COALESCE(
+ (SELECT rl.is_automated FROM reply_labels rl
+ WHERE rl.workspace_id = e.workspace_id AND rl.key = $3::text),
+ $3::text IN ('auto_reply', 'out_of_office'))
+ AND EXISTS (
+ SELECT 1 FROM sequence_step_branches b
+ JOIN sequence_steps s ON s.id = b.step_id AND s.campaign_id = b.campaign_id
+ WHERE b.campaign_id = e.campaign_id AND b.workspace_id = e.workspace_id
+ AND s.step_order = e.current_step
+ AND b.condition IN ('replied', 'not_replied'))
+`
+
+type NudgeEnrollmentAwaitingReplyParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ReplyClass string `json:"reply_class"`
+}
+
+// A reply that did NOT stop the enrollment just arrived. If the enrollment is
+// waiting on a reply condition at its current step, pull its due time to now so
+// the next sweep evaluates the condition instead of waiting out the re-check
+// interval. Automated replies (out-of-office, auto-reply) are excluded: they are
+// the only replies that defer an enrollment, and pulling next_due_at forward
+// would undo that deferral. Never pushes a due time LATER, and is a no-op for
+// every enrollment without a reply condition — which is every linear campaign.
+//
+// awaiting_condition_step = current_step is the second half of that deferral
+// guard: it proves the due time being pulled forward was stamped by a CONDITION
+// wait at this step. An out-of-office deferral is stamped by DeferEnrollment,
+// which never touches that column, so an enrollment whose current due time is a
+// stated absence (not yet parked by a condition at this step) is left alone.
+func (q *Queries) NudgeEnrollmentAwaitingReply(ctx context.Context, arg NudgeEnrollmentAwaitingReplyParams) error {
+ _, err := q.db.Exec(ctx, nudgeEnrollmentAwaitingReply, arg.ID, arg.WorkspaceID, arg.ReplyClass)
+ return err
+}
+
const setEnrollmentDue = `-- name: SetEnrollmentDue :exec
UPDATE sequence_enrollments
SET next_due_at = $3
diff --git a/internal/platform/db/gen/models.go b/internal/platform/db/gen/models.go
index 551506d5..1160f5c6 100644
--- a/internal/platform/db/gen/models.go
+++ b/internal/platform/db/gen/models.go
@@ -959,25 +959,26 @@ type SendingDomain struct {
}
type SequenceEnrollment struct {
- ID uuid.UUID `json:"id"`
- WorkspaceID uuid.UUID `json:"workspace_id"`
- CampaignID uuid.UUID `json:"campaign_id"`
- ContactID uuid.UUID `json:"contact_id"`
- CurrentStep int32 `json:"current_step"`
- Status string `json:"status"`
- StopReason *string `json:"stop_reason"`
- EnrolledAt pgtype.Timestamptz `json:"enrolled_at"`
- LastSentAt pgtype.Timestamptz `json:"last_sent_at"`
- NextDueAt pgtype.Timestamptz `json:"next_due_at"`
- ThreadRootID string `json:"thread_root_id"`
- CompletedAt pgtype.Timestamptz `json:"completed_at"`
- StoppedAt pgtype.Timestamptz `json:"stopped_at"`
- CapDeferrals int32 `json:"cap_deferrals"`
- ReplyClass *string `json:"reply_class"`
- ReplySource *string `json:"reply_source"`
- ReplyConfidence *float32 `json:"reply_confidence"`
- RepliedAt pgtype.Timestamptz `json:"replied_at"`
- MailboxID pgtype.UUID `json:"mailbox_id"`
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ CampaignID uuid.UUID `json:"campaign_id"`
+ ContactID uuid.UUID `json:"contact_id"`
+ CurrentStep int32 `json:"current_step"`
+ Status string `json:"status"`
+ StopReason *string `json:"stop_reason"`
+ EnrolledAt pgtype.Timestamptz `json:"enrolled_at"`
+ LastSentAt pgtype.Timestamptz `json:"last_sent_at"`
+ NextDueAt pgtype.Timestamptz `json:"next_due_at"`
+ ThreadRootID string `json:"thread_root_id"`
+ CompletedAt pgtype.Timestamptz `json:"completed_at"`
+ StoppedAt pgtype.Timestamptz `json:"stopped_at"`
+ CapDeferrals int32 `json:"cap_deferrals"`
+ ReplyClass *string `json:"reply_class"`
+ ReplySource *string `json:"reply_source"`
+ ReplyConfidence *float32 `json:"reply_confidence"`
+ RepliedAt pgtype.Timestamptz `json:"replied_at"`
+ MailboxID pgtype.UUID `json:"mailbox_id"`
+ AwaitingConditionStep *int32 `json:"awaiting_condition_step"`
}
type SequenceStep struct {
@@ -994,6 +995,19 @@ type SequenceStep struct {
VariantWeight int32 `json:"variant_weight"`
}
+type SequenceStepBranch struct {
+ StepID uuid.UUID `json:"step_id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ CampaignID uuid.UUID `json:"campaign_id"`
+ Condition string `json:"condition"`
+ WithinDays *int32 `json:"within_days"`
+ ReplyLabelKey *string `json:"reply_label_key"`
+ YesStepID pgtype.UUID `json:"yes_step_id"`
+ NoStepID pgtype.UUID `json:"no_step_id"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+}
+
type SequenceStepVariant struct {
ID uuid.UUID `json:"id"`
WorkspaceID uuid.UUID `json:"workspace_id"`
diff --git a/internal/platform/db/gen/stepbranch.sql.go b/internal/platform/db/gen/stepbranch.sql.go
new file mode 100644
index 00000000..c4487bd5
--- /dev/null
+++ b/internal/platform/db/gen/stepbranch.sql.go
@@ -0,0 +1,465 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.31.1
+// source: stepbranch.sql
+
+package gen
+
+import (
+ "context"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const campaignTrackingEnabled = `-- name: CampaignTrackingEnabled :one
+SELECT tracking_enabled FROM campaigns WHERE id = $1 AND workspace_id = $2
+`
+
+type CampaignTrackingEnabledParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+// Whether the campaign rewrites links and embeds the open pixel. Save-time
+// validation for open/click branches, which have no evidence to read otherwise.
+func (q *Queries) CampaignTrackingEnabled(ctx context.Context, arg CampaignTrackingEnabledParams) (bool, error) {
+ row := q.db.QueryRow(ctx, campaignTrackingEnabled, arg.ID, arg.WorkspaceID)
+ var tracking_enabled bool
+ err := row.Scan(&tracking_enabled)
+ return tracking_enabled, err
+}
+
+const deleteBranch = `-- name: DeleteBranch :exec
+DELETE FROM sequence_step_branches
+WHERE step_id = $1 AND campaign_id = $2 AND workspace_id = $3
+`
+
+type DeleteBranchParams struct {
+ StepID uuid.UUID `json:"step_id"`
+ CampaignID uuid.UUID `json:"campaign_id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+// Remove a step's router, returning it to linear fall-through. Pinned on
+// workspace_id and campaign_id.
+func (q *Queries) DeleteBranch(ctx context.Context, arg DeleteBranchParams) error {
+ _, err := q.db.Exec(ctx, deleteBranch, arg.StepID, arg.CampaignID, arg.WorkspaceID)
+ return err
+}
+
+const deleteBranchIfUnchanged = `-- name: DeleteBranchIfUnchanged :execrows
+DELETE FROM sequence_step_branches
+WHERE step_id = $1 AND campaign_id = $2 AND workspace_id = $3
+ AND updated_at = $4::timestamptz
+`
+
+type DeleteBranchIfUnchangedParams struct {
+ StepID uuid.UUID `json:"step_id"`
+ CampaignID uuid.UUID `json:"campaign_id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ExpectedUpdatedAt pgtype.Timestamptz `json:"expected_updated_at"`
+}
+
+// Remove a step's router only if its updated_at still equals the value the
+// client last read. Zero rows means it changed or was already removed.
+func (q *Queries) DeleteBranchIfUnchanged(ctx context.Context, arg DeleteBranchIfUnchangedParams) (int64, error) {
+ result, err := q.db.Exec(ctx, deleteBranchIfUnchanged,
+ arg.StepID,
+ arg.CampaignID,
+ arg.WorkspaceID,
+ arg.ExpectedUpdatedAt,
+ )
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const firstHumanTrackingEventAt = `-- name: FirstHumanTrackingEventAt :one
+SELECT created_at FROM tracking_events
+WHERE send_id = $1 AND workspace_id = $2 AND kind = $3 AND NOT is_machine
+ AND created_at <= $4::timestamptz
+ORDER BY created_at ASC
+LIMIT 1
+`
+
+type FirstHumanTrackingEventAtParams struct {
+ SendID uuid.UUID `json:"send_id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ Kind TrackingEventKind `json:"kind"`
+ WindowEnd pgtype.Timestamptz `json:"window_end"`
+}
+
+// The earliest HUMAN open or click of one send, at or before window_end. It reads
+// the stored bot verdict (NOT is_machine) — the same definition CountHumanOpens
+// reports — rather than deriving its own, so a branch and the open rate can
+// never disagree about the same contact (docs/security.md invariant 84). Served
+// by idx_tracking_send_recent (send_id, kind, is_machine, created_at).
+func (q *Queries) FirstHumanTrackingEventAt(ctx context.Context, arg FirstHumanTrackingEventAtParams) (pgtype.Timestamptz, error) {
+ row := q.db.QueryRow(ctx, firstHumanTrackingEventAt,
+ arg.SendID,
+ arg.WorkspaceID,
+ arg.Kind,
+ arg.WindowEnd,
+ )
+ var created_at pgtype.Timestamptz
+ err := row.Scan(&created_at)
+ return created_at, err
+}
+
+const firstInboundReplyAt = `-- name: FirstInboundReplyAt :one
+SELECT m.created_at FROM inbox_messages m
+JOIN inbox_threads t ON t.id = m.thread_id AND t.workspace_id = m.workspace_id
+LEFT JOIN reply_labels rl ON rl.workspace_id = m.workspace_id AND rl.key = m.reply_class
+WHERE m.workspace_id = $1 AND t.campaign_id = $2 AND t.contact_id = $3
+ AND m.direction = 'inbound'
+ AND m.created_at >= $4::timestamptz
+ AND m.created_at <= $5::timestamptz
+ AND CASE WHEN $6::text = ''
+ THEN NOT COALESCE(rl.is_automated, m.reply_class IN ('auto_reply', 'out_of_office'))
+ ELSE m.reply_class = $6::text
+ END
+ORDER BY m.created_at ASC
+LIMIT 1
+`
+
+type FirstInboundReplyAtParams struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ CampaignID pgtype.UUID `json:"campaign_id"`
+ ContactID pgtype.UUID `json:"contact_id"`
+ WindowStart pgtype.Timestamptz `json:"window_start"`
+ WindowEnd pgtype.Timestamptz `json:"window_end"`
+ LabelKey string `json:"label_key"`
+}
+
+// The earliest inbound reply from this contact on this campaign that arrived
+// inside [window_start, window_end]. created_at (when WE ingested it), not
+// occurred_at: the latter is the sender's own Date header, which they control.
+//
+// label_key ” means "any human reply": a message whose label is automated
+// (out-of-office, auto-reply) is not a reply from a person and does not count. A
+// label the workspace has since deleted falls back to the builtin automated keys,
+// the same degradation the inbox dispatch applies. A non-empty label_key matches
+// that key exactly, automated or not — branching on an out-of-office is a
+// legitimate thing to want.
+func (q *Queries) FirstInboundReplyAt(ctx context.Context, arg FirstInboundReplyAtParams) (pgtype.Timestamptz, error) {
+ row := q.db.QueryRow(ctx, firstInboundReplyAt,
+ arg.WorkspaceID,
+ arg.CampaignID,
+ arg.ContactID,
+ arg.WindowStart,
+ arg.WindowEnd,
+ arg.LabelKey,
+ )
+ var created_at pgtype.Timestamptz
+ err := row.Scan(&created_at)
+ return created_at, err
+}
+
+const getBranch = `-- name: GetBranch :one
+SELECT step_id, workspace_id, campaign_id, condition, within_days, reply_label_key, yes_step_id, no_step_id, created_at, updated_at FROM sequence_step_branches
+WHERE step_id = $1 AND campaign_id = $2 AND workspace_id = $3
+`
+
+type GetBranchParams struct {
+ StepID uuid.UUID `json:"step_id"`
+ CampaignID uuid.UUID `json:"campaign_id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+// One step's router, workspace- and campaign-pinned. Read inside a refused
+// precondition's transaction, so the 409 carries the branch that won.
+func (q *Queries) GetBranch(ctx context.Context, arg GetBranchParams) (SequenceStepBranch, error) {
+ row := q.db.QueryRow(ctx, getBranch, arg.StepID, arg.CampaignID, arg.WorkspaceID)
+ var i SequenceStepBranch
+ err := row.Scan(
+ &i.StepID,
+ &i.WorkspaceID,
+ &i.CampaignID,
+ &i.Condition,
+ &i.WithinDays,
+ &i.ReplyLabelKey,
+ &i.YesStepID,
+ &i.NoStepID,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const insertBranchIfAbsent = `-- name: InsertBranchIfAbsent :one
+INSERT INTO sequence_step_branches (step_id, workspace_id, campaign_id, condition, within_days,
+ reply_label_key, yes_step_id, no_step_id)
+VALUES ($1, $2, $3, $4, $5, $6,
+ $7, $8)
+ON CONFLICT (step_id) DO NOTHING
+RETURNING step_id, workspace_id, campaign_id, condition, within_days, reply_label_key, yes_step_id, no_step_id, created_at, updated_at
+`
+
+type InsertBranchIfAbsentParams struct {
+ StepID uuid.UUID `json:"step_id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ CampaignID uuid.UUID `json:"campaign_id"`
+ Condition string `json:"condition"`
+ WithinDays *int32 `json:"within_days"`
+ ReplyLabelKey *string `json:"reply_label_key"`
+ YesStepID pgtype.UUID `json:"yes_step_id"`
+ NoStepID pgtype.UUID `json:"no_step_id"`
+}
+
+// Create the router on one step only if the step has none: the "expect no
+// branch" precondition. No row returned means one already exists (the caller
+// reads it with GetBranch to report it). Atomic on its own via the primary key,
+// so two concurrent creates cannot both succeed even without the graph lock.
+func (q *Queries) InsertBranchIfAbsent(ctx context.Context, arg InsertBranchIfAbsentParams) (SequenceStepBranch, error) {
+ row := q.db.QueryRow(ctx, insertBranchIfAbsent,
+ arg.StepID,
+ arg.WorkspaceID,
+ arg.CampaignID,
+ arg.Condition,
+ arg.WithinDays,
+ arg.ReplyLabelKey,
+ arg.YesStepID,
+ arg.NoStepID,
+ )
+ var i SequenceStepBranch
+ err := row.Scan(
+ &i.StepID,
+ &i.WorkspaceID,
+ &i.CampaignID,
+ &i.Condition,
+ &i.WithinDays,
+ &i.ReplyLabelKey,
+ &i.YesStepID,
+ &i.NoStepID,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const listBranchesByCampaign = `-- name: ListBranchesByCampaign :many
+SELECT step_id, workspace_id, campaign_id, condition, within_days, reply_label_key, yes_step_id, no_step_id, created_at, updated_at FROM sequence_step_branches
+WHERE campaign_id = $1 AND workspace_id = $2
+ORDER BY step_id
+`
+
+type ListBranchesByCampaignParams struct {
+ CampaignID uuid.UUID `json:"campaign_id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+// Every router in the campaign, workspace-pinned. The send path reads this once
+// per advance: an empty result is the linear campaign, which then takes exactly
+// the pre-branching code path.
+func (q *Queries) ListBranchesByCampaign(ctx context.Context, arg ListBranchesByCampaignParams) ([]SequenceStepBranch, error) {
+ rows, err := q.db.Query(ctx, listBranchesByCampaign, arg.CampaignID, arg.WorkspaceID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []SequenceStepBranch
+ for rows.Next() {
+ var i SequenceStepBranch
+ if err := rows.Scan(
+ &i.StepID,
+ &i.WorkspaceID,
+ &i.CampaignID,
+ &i.Condition,
+ &i.WithinDays,
+ &i.ReplyLabelKey,
+ &i.YesStepID,
+ &i.NoStepID,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const lockCampaignGraph = `-- name: LockCampaignGraph :one
+SELECT id FROM campaigns WHERE id = $1 AND workspace_id = $2 FOR NO KEY UPDATE
+`
+
+type LockCampaignGraphParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+// Serializes every write that changes a campaign's routing graph (branch
+// upsert/delete, step delete, reorder), so the save-time cycle check sees the
+// graph it is actually committing into — two edits that are each acyclic alone
+// can form a cycle together. FOR NO KEY UPDATE rather than FOR UPDATE: it still
+// conflicts with itself, but not with the FOR KEY SHARE lock every sends insert
+// takes on its campaign FK, so a graph edit never stalls delivery.
+func (q *Queries) LockCampaignGraph(ctx context.Context, arg LockCampaignGraphParams) (uuid.UUID, error) {
+ row := q.db.QueryRow(ctx, lockCampaignGraph, arg.ID, arg.WorkspaceID)
+ var id uuid.UUID
+ err := row.Scan(&id)
+ return id, err
+}
+
+const replyLabelStopsEnrollment = `-- name: ReplyLabelStopsEnrollment :one
+SELECT stops_enrollment FROM reply_labels WHERE workspace_id = $1 AND key = $2
+`
+
+type ReplyLabelStopsEnrollmentParams struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ Key string `json:"key"`
+}
+
+// Whether the workspace's reply label with this key stops the enrollment.
+// Save-time validation only: no row (pgx.ErrNoRows) means the label does not
+// exist and a branch naming it could never match; true means a reply with that
+// label stops the sequence before any branch can route it, so a branch naming it
+// could never fire either.
+func (q *Queries) ReplyLabelStopsEnrollment(ctx context.Context, arg ReplyLabelStopsEnrollmentParams) (bool, error) {
+ row := q.db.QueryRow(ctx, replyLabelStopsEnrollment, arg.WorkspaceID, arg.Key)
+ var stops_enrollment bool
+ err := row.Scan(&stops_enrollment)
+ return stops_enrollment, err
+}
+
+const stepSendCreatedAt = `-- name: StepSendCreatedAt :one
+SELECT created_at FROM sends WHERE id = $1 AND workspace_id = $2
+`
+
+type StepSendCreatedAtParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+// When a (deterministically-id'd) step send row was first created. The send
+// path's cycle backstop compares the routed step's row with the CURRENT step's
+// own row: a routed step created before the step the contact is on was visited
+// EARLIER on this path, i.e. the graph now loops. Workspace-pinned.
+func (q *Queries) StepSendCreatedAt(ctx context.Context, arg StepSendCreatedAtParams) (pgtype.Timestamptz, error) {
+ row := q.db.QueryRow(ctx, stepSendCreatedAt, arg.ID, arg.WorkspaceID)
+ var created_at pgtype.Timestamptz
+ err := row.Scan(&created_at)
+ return created_at, err
+}
+
+const updateBranchIfUnchanged = `-- name: UpdateBranchIfUnchanged :one
+UPDATE sequence_step_branches
+SET condition = $1, within_days = $2,
+ reply_label_key = $3, yes_step_id = $4,
+ no_step_id = $5
+WHERE step_id = $6 AND workspace_id = $7
+ AND campaign_id = $8
+ AND updated_at = $9::timestamptz
+RETURNING step_id, workspace_id, campaign_id, condition, within_days, reply_label_key, yes_step_id, no_step_id, created_at, updated_at
+`
+
+type UpdateBranchIfUnchangedParams struct {
+ Condition string `json:"condition"`
+ WithinDays *int32 `json:"within_days"`
+ ReplyLabelKey *string `json:"reply_label_key"`
+ YesStepID pgtype.UUID `json:"yes_step_id"`
+ NoStepID pgtype.UUID `json:"no_step_id"`
+ StepID uuid.UUID `json:"step_id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ CampaignID uuid.UUID `json:"campaign_id"`
+ ExpectedUpdatedAt pgtype.Timestamptz `json:"expected_updated_at"`
+}
+
+// Replace the router on one step only if its updated_at still equals the value
+// the client last read: the "expect this version" precondition. No row returned
+// means the branch changed or is gone. Compared at full (microsecond) precision;
+// the trigger guarantees every write moves the value. Pinned on workspace_id and
+// campaign_id.
+func (q *Queries) UpdateBranchIfUnchanged(ctx context.Context, arg UpdateBranchIfUnchangedParams) (SequenceStepBranch, error) {
+ row := q.db.QueryRow(ctx, updateBranchIfUnchanged,
+ arg.Condition,
+ arg.WithinDays,
+ arg.ReplyLabelKey,
+ arg.YesStepID,
+ arg.NoStepID,
+ arg.StepID,
+ arg.WorkspaceID,
+ arg.CampaignID,
+ arg.ExpectedUpdatedAt,
+ )
+ var i SequenceStepBranch
+ err := row.Scan(
+ &i.StepID,
+ &i.WorkspaceID,
+ &i.CampaignID,
+ &i.Condition,
+ &i.WithinDays,
+ &i.ReplyLabelKey,
+ &i.YesStepID,
+ &i.NoStepID,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const upsertBranch = `-- name: UpsertBranch :one
+INSERT INTO sequence_step_branches (step_id, workspace_id, campaign_id, condition, within_days,
+ reply_label_key, yes_step_id, no_step_id)
+VALUES ($1, $2, $3, $4, $5, $6,
+ $7, $8)
+ON CONFLICT (step_id) DO UPDATE
+SET condition = EXCLUDED.condition, within_days = EXCLUDED.within_days,
+ reply_label_key = EXCLUDED.reply_label_key, yes_step_id = EXCLUDED.yes_step_id,
+ no_step_id = EXCLUDED.no_step_id
+WHERE sequence_step_branches.workspace_id = EXCLUDED.workspace_id
+ AND sequence_step_branches.campaign_id = EXCLUDED.campaign_id
+RETURNING step_id, workspace_id, campaign_id, condition, within_days, reply_label_key, yes_step_id, no_step_id, created_at, updated_at
+`
+
+type UpsertBranchParams struct {
+ StepID uuid.UUID `json:"step_id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ CampaignID uuid.UUID `json:"campaign_id"`
+ Condition string `json:"condition"`
+ WithinDays *int32 `json:"within_days"`
+ ReplyLabelKey *string `json:"reply_label_key"`
+ YesStepID pgtype.UUID `json:"yes_step_id"`
+ NoStepID pgtype.UUID `json:"no_step_id"`
+}
+
+// Create or replace the router on one step. workspace_id and campaign_id are
+// written from the caller's pinned values, and the composite FKs refuse a step
+// (source or target) that is not in that campaign, so a foreign id cannot be
+// smuggled in even if the service's own check were skipped. The ON CONFLICT
+// update is pinned on workspace_id as well: a step id belonging to another tenant
+// updates nothing and returns no row. updated_at is not written here: a trigger
+// advances it on every insert and update (migration
+// 20260923161044_step_branch_updated_at_advances), because it is the branch's
+// concurrency token.
+func (q *Queries) UpsertBranch(ctx context.Context, arg UpsertBranchParams) (SequenceStepBranch, error) {
+ row := q.db.QueryRow(ctx, upsertBranch,
+ arg.StepID,
+ arg.WorkspaceID,
+ arg.CampaignID,
+ arg.Condition,
+ arg.WithinDays,
+ arg.ReplyLabelKey,
+ arg.YesStepID,
+ arg.NoStepID,
+ )
+ var i SequenceStepBranch
+ err := row.Scan(
+ &i.StepID,
+ &i.WorkspaceID,
+ &i.CampaignID,
+ &i.Condition,
+ &i.WithinDays,
+ &i.ReplyLabelKey,
+ &i.YesStepID,
+ &i.NoStepID,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
diff --git a/internal/platform/db/gen/stepsend.sql.go b/internal/platform/db/gen/stepsend.sql.go
index e834d241..28da9e1f 100644
--- a/internal/platform/db/gen/stepsend.sql.go
+++ b/internal/platform/db/gen/stepsend.sql.go
@@ -102,6 +102,7 @@ func (q *Queries) ClaimStepSend(ctx context.Context, arg ClaimStepSendParams) (C
const getStepEnrollmentBundle = `-- name: GetStepEnrollmentBundle :one
SELECT e.id AS enrollment_id, e.workspace_id, e.contact_id, e.current_step,
e.status, e.thread_root_id, e.next_due_at, e.mailbox_id AS enrollment_mailbox_id,
+ e.last_sent_at, e.awaiting_condition_step,
cam.id AS campaign_id, cam.rotation_mode, cam.tracking_enabled, cam.timezone,
cam.daily_limit, cam.max_new_leads_per_day, cam.status AS campaign_status,
ct.email AS to_email, ct.first_name, ct.last_name, ct.company, ct.custom_fields,
@@ -122,41 +123,43 @@ type GetStepEnrollmentBundleParams struct {
}
type GetStepEnrollmentBundleRow struct {
- EnrollmentID uuid.UUID `json:"enrollment_id"`
- WorkspaceID uuid.UUID `json:"workspace_id"`
- ContactID uuid.UUID `json:"contact_id"`
- CurrentStep int32 `json:"current_step"`
- Status string `json:"status"`
- ThreadRootID string `json:"thread_root_id"`
- NextDueAt pgtype.Timestamptz `json:"next_due_at"`
- EnrollmentMailboxID pgtype.UUID `json:"enrollment_mailbox_id"`
- CampaignID uuid.UUID `json:"campaign_id"`
- RotationMode string `json:"rotation_mode"`
- TrackingEnabled bool `json:"tracking_enabled"`
- Timezone string `json:"timezone"`
- DailyLimit *int32 `json:"daily_limit"`
- MaxNewLeadsPerDay *int32 `json:"max_new_leads_per_day"`
- CampaignStatus string `json:"campaign_status"`
- ToEmail string `json:"to_email"`
- FirstName string `json:"first_name"`
- LastName string `json:"last_name"`
- Company string `json:"company"`
- CustomFields []byte `json:"custom_fields"`
- MailboxID uuid.UUID `json:"mailbox_id"`
- Provider string `json:"provider"`
- FromEmail string `json:"from_email"`
- FromName string `json:"from_name"`
- SmtpHost string `json:"smtp_host"`
- SmtpPort int32 `json:"smtp_port"`
- SmtpUsername string `json:"smtp_username"`
- SecretCiphertext string `json:"secret_ciphertext"`
- AllowPlaintext bool `json:"allow_plaintext"`
- DailyCap int32 `json:"daily_cap"`
- MinIntervalSeconds int32 `json:"min_interval_seconds"`
- RampEnabled bool `json:"ramp_enabled"`
- RampStartCap int32 `json:"ramp_start_cap"`
- RampDays int32 `json:"ramp_days"`
- MailboxCreatedAt pgtype.Timestamptz `json:"mailbox_created_at"`
+ EnrollmentID uuid.UUID `json:"enrollment_id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ContactID uuid.UUID `json:"contact_id"`
+ CurrentStep int32 `json:"current_step"`
+ Status string `json:"status"`
+ ThreadRootID string `json:"thread_root_id"`
+ NextDueAt pgtype.Timestamptz `json:"next_due_at"`
+ EnrollmentMailboxID pgtype.UUID `json:"enrollment_mailbox_id"`
+ LastSentAt pgtype.Timestamptz `json:"last_sent_at"`
+ AwaitingConditionStep *int32 `json:"awaiting_condition_step"`
+ CampaignID uuid.UUID `json:"campaign_id"`
+ RotationMode string `json:"rotation_mode"`
+ TrackingEnabled bool `json:"tracking_enabled"`
+ Timezone string `json:"timezone"`
+ DailyLimit *int32 `json:"daily_limit"`
+ MaxNewLeadsPerDay *int32 `json:"max_new_leads_per_day"`
+ CampaignStatus string `json:"campaign_status"`
+ ToEmail string `json:"to_email"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ Company string `json:"company"`
+ CustomFields []byte `json:"custom_fields"`
+ MailboxID uuid.UUID `json:"mailbox_id"`
+ Provider string `json:"provider"`
+ FromEmail string `json:"from_email"`
+ FromName string `json:"from_name"`
+ SmtpHost string `json:"smtp_host"`
+ SmtpPort int32 `json:"smtp_port"`
+ SmtpUsername string `json:"smtp_username"`
+ SecretCiphertext string `json:"secret_ciphertext"`
+ AllowPlaintext bool `json:"allow_plaintext"`
+ DailyCap int32 `json:"daily_cap"`
+ MinIntervalSeconds int32 `json:"min_interval_seconds"`
+ RampEnabled bool `json:"ramp_enabled"`
+ RampStartCap int32 `json:"ramp_start_cap"`
+ RampDays int32 `json:"ramp_days"`
+ MailboxCreatedAt pgtype.Timestamptz `json:"mailbox_created_at"`
}
// Everything needed to build one step-send job, workspace-pinned. Joins the
@@ -188,6 +191,8 @@ func (q *Queries) GetStepEnrollmentBundle(ctx context.Context, arg GetStepEnroll
&i.ThreadRootID,
&i.NextDueAt,
&i.EnrollmentMailboxID,
+ &i.LastSentAt,
+ &i.AwaitingConditionStep,
&i.CampaignID,
&i.RotationMode,
&i.TrackingEnabled,
@@ -221,14 +226,15 @@ func (q *Queries) GetStepEnrollmentBundle(ctx context.Context, arg GetStepEnroll
const latestSentForContact = `-- name: LatestSentForContact :one
SELECT message_id, references_header FROM sends
-WHERE campaign_id = $1 AND contact_id = $2 AND status = 'sent'
-ORDER BY step_order DESC
+WHERE campaign_id = $1 AND contact_id = $2 AND workspace_id = $3 AND status = 'sent'
+ORDER BY sent_at DESC NULLS LAST, step_order DESC
LIMIT 1
`
type LatestSentForContactParams struct {
- CampaignID uuid.UUID `json:"campaign_id"`
- ContactID uuid.UUID `json:"contact_id"`
+ CampaignID uuid.UUID `json:"campaign_id"`
+ ContactID uuid.UUID `json:"contact_id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
}
type LatestSentForContactRow struct {
@@ -238,8 +244,18 @@ type LatestSentForContactRow struct {
// The most recent successfully-sent step for a (campaign, contact), used to
// thread the next step (In-Reply-To = its message_id; References = its chain).
+//
+// "Most recent" is by sent_at, with step_order only as the tie-break. On a linear
+// sequence the two orders are the same (steps send in step_order), but a branched
+// path need not visit steps in step_order — 1 → 3 → 2 is a valid path — and
+// threading onto the highest-numbered step would reply to a message that is not
+// the latest one the contact received.
+//
+// Workspace-pinned like every other tenant read, even though (campaign_id,
+// contact_id) already came from a workspace-scoped bundle: the pin costs nothing
+// and keeps this from depending on its caller's discipline.
func (q *Queries) LatestSentForContact(ctx context.Context, arg LatestSentForContactParams) (LatestSentForContactRow, error) {
- row := q.db.QueryRow(ctx, latestSentForContact, arg.CampaignID, arg.ContactID)
+ row := q.db.QueryRow(ctx, latestSentForContact, arg.CampaignID, arg.ContactID, arg.WorkspaceID)
var i LatestSentForContactRow
err := row.Scan(&i.MessageID, &i.ReferencesHeader)
return i, err
diff --git a/internal/platform/db/migrate.go b/internal/platform/db/migrate.go
index b15c18ea..66959e93 100644
--- a/internal/platform/db/migrate.go
+++ b/internal/platform/db/migrate.go
@@ -13,29 +13,24 @@ import (
var migrationsFS embed.FS
// Migrate applies all up migrations. It is a no-op if the schema is current.
-func Migrate(url string) (err error) {
- m, err := newMigrator(url)
- if err != nil {
- return err
- }
- defer func() { err = errors.Join(err, closeMigrator(m)) }()
- if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
- return err
- }
- return nil
+// Concurrent callers on one database are serialized (see migratelock.go).
+func Migrate(url string) error {
+ return withMigrator(url, func(m *migrate.Migrate) error {
+ if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
+ return err
+ }
+ return nil
+ })
}
// MigrateDown rolls back a single migration.
-func MigrateDown(url string) (err error) {
- m, err := newMigrator(url)
- if err != nil {
- return err
- }
- defer func() { err = errors.Join(err, closeMigrator(m)) }()
- if err := m.Steps(-1); err != nil && !errors.Is(err, migrate.ErrNoChange) {
- return err
- }
- return nil
+func MigrateDown(url string) error {
+ return withMigrator(url, func(m *migrate.Migrate) error {
+ if err := m.Steps(-1); err != nil && !errors.Is(err, migrate.ErrNoChange) {
+ return err
+ }
+ return nil
+ })
}
// MigrateTo rolls the schema to an exact version, applying or reverting whatever
@@ -51,16 +46,13 @@ func MigrateDown(url string) (err error) {
// A test that names the version it wants to land on is correct no matter how many
// migrations follow it: MigrateTo(dsn, N-1) undoes migration N whatever N+1, N+2
// do later.
-func MigrateTo(url string, version uint) (err error) {
- m, err := newMigrator(url)
- if err != nil {
- return err
- }
- defer func() { err = errors.Join(err, closeMigrator(m)) }()
- if err := m.Migrate(version); err != nil && !errors.Is(err, migrate.ErrNoChange) {
- return err
- }
- return nil
+func MigrateTo(url string, version uint) error {
+ return withMigrator(url, func(m *migrate.Migrate) error {
+ if err := m.Migrate(version); err != nil && !errors.Is(err, migrate.ErrNoChange) {
+ return err
+ }
+ return nil
+ })
}
// Version reports the schema's current migration version and whether it was
@@ -69,19 +61,38 @@ func MigrateTo(url string, version uint) (err error) {
// error nil — golang-migrate's own ErrNilVersion for that case is a
// library-specific sentinel a caller three packages away has no reason to
// know about, so it is translated here rather than propagated.
+//
+// It takes the migration lock like the writers do, because merely opening
+// golang-migrate's driver takes the library's own lock (ensureVersionTable), and
+// a blocked wait for that lock can deadlock a running CREATE INDEX CONCURRENTLY.
+// A status probe during a deploy therefore waits for the migration to finish.
func Version(url string) (version uint, dirty bool, err error) {
- m, err := newMigrator(url)
- if err != nil {
- return 0, false, err
- }
- defer func() { err = errors.Join(err, closeMigrator(m)) }()
- version, dirty, err = m.Version()
- if errors.Is(err, migrate.ErrNilVersion) {
- return 0, false, nil
- }
+ err = withMigrator(url, func(m *migrate.Migrate) error {
+ var verr error
+ version, dirty, verr = m.Version()
+ if errors.Is(verr, migrate.ErrNilVersion) {
+ version, dirty, verr = 0, false, nil
+ }
+ return verr
+ })
return version, dirty, err
}
+// withMigrator holds the migration lock (migratelock.go), builds a migrator,
+// runs fn and closes the migrator, in that order: the migrator must be
+// constructed UNDER the lock, since constructing it is what takes
+// golang-migrate's own lock.
+func withMigrator(url string, fn func(m *migrate.Migrate) error) error {
+ return withMigrationLock(url, func() (err error) {
+ m, err := newMigrator(url)
+ if err != nil {
+ return err
+ }
+ defer func() { err = errors.Join(err, closeMigrator(m)) }()
+ return fn(m)
+ })
+}
+
func newMigrator(url string) (*migrate.Migrate, error) {
src, err := iofs.New(migrationsFS, "migrations")
if err != nil {
diff --git a/internal/platform/db/migratelock.go b/internal/platform/db/migratelock.go
new file mode 100644
index 00000000..e469fba4
--- /dev/null
+++ b/internal/platform/db/migratelock.go
@@ -0,0 +1,124 @@
+package db
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+)
+
+// Serializing migrators BEFORE golang-migrate gets involved.
+//
+// golang-migrate already takes an advisory lock, and that lock is the problem.
+// Its pgx/v5 driver waits for it with a blocking `SELECT pg_advisory_lock(..)` —
+// a statement that stays RUNNING, and so holds a snapshot, for as long as the
+// wait lasts. `CREATE INDEX CONCURRENTLY` must wait out every snapshot older
+// than its own before it can finish. So when two processes migrate the same
+// database at once and the first reaches a CONCURRENTLY file, the first waits
+// for the second's snapshot while the second waits for the first's lock: a
+// deadlock Postgres resolves by aborting the index build, which leaves the
+// schema DIRTY at that version. Parallel integration packages on a fresh test
+// database and replicas that migrate on boot both hit it.
+//
+// The fix is to never wait inside a statement. Every entry point below first
+// takes a SEPARATE session-level advisory lock by POLLING pg_try_advisory_lock
+// — each try is a statement that returns at once, and between tries the
+// session is idle and holds no snapshot. Only the holder of that lock ever
+// constructs a migrator, so golang-migrate's own lock is always uncontended and
+// its blocking wait never happens.
+//
+// The lock is taken for EVERY migrator, including Version: opening the driver
+// runs ensureVersionTable, which takes golang-migrate's lock too, so a status
+// probe during a deploy is exactly as able to deadlock a CONCURRENTLY build as
+// a second migrator is.
+
+// migrationLockKey is this package's advisory-lock key. It must differ from
+// golang-migrate's own key, which that library derives from the database
+// name; this is an arbitrary fixed value ("inroadmg" as bytes). Advisory locks
+// are scoped to the database, so two databases on one server never contend.
+const migrationLockKey int64 = 0x696e726f61646d67
+
+// migrationLockPoll is the pause between lock attempts: short enough that a
+// waiter starts promptly after the holder finishes, long enough that N waiters
+// polling do not load the server.
+const migrationLockPoll = 250 * time.Millisecond
+
+// migrationLockTimeout bounds how long a process waits for another's migration.
+// Generous because a migration can legitimately run for minutes (a backfill,
+// an index build on a large table); a waiter that gives up returns an error
+// rather than proceeding, so the bound only decides how long a stuck deploy
+// takes to say so.
+const migrationLockTimeout = 15 * time.Minute
+
+// ErrMigrationLockTimeout is returned when another process held the migration
+// lock for longer than the wait allowed.
+var ErrMigrationLockTimeout = errors.New("db: timed out waiting for another process's migration to finish")
+
+// withMigrationLock runs fn while holding the migration lock on url's database.
+//
+// The exported migrate functions take no context (neither does golang-migrate's
+// API), and they are top-level operations — a binary's startup, a CLI command, a
+// test's setup — so this is where their deadline is chosen, rather than
+// inherited: migrationLockTimeout for the wait, and fn itself runs unbounded
+// under the held lock, exactly as it did before this lock existed.
+func withMigrationLock(url string, fn func() error) (err error) {
+ ctx, cancel := context.WithTimeout(context.Background(), migrationLockTimeout)
+ defer cancel()
+ return withMigrationLockCtx(ctx, url, migrationLockPoll, fn)
+}
+
+// withMigrationLockCtx is withMigrationLock with the wait bounded by ctx and
+// the poll interval injectable (tests).
+func withMigrationLockCtx(ctx context.Context, url string, poll time.Duration, fn func() error) (err error) {
+ conn, err := pgx.Connect(ctx, WithoutPoolParams(url))
+ if err != nil {
+ return fmt.Errorf("migration lock: connect: %w", err)
+ }
+ // Closing the session releases a session-level advisory lock even if the
+ // explicit unlock below never ran. A fresh context: the wait's may already
+ // be spent, and releasing must not be cancelled with it.
+ defer func() {
+ closeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
+ defer cancel()
+ err = errors.Join(err, conn.Close(closeCtx))
+ }()
+
+ if err := acquireMigrationLock(ctx, conn, poll); err != nil {
+ return err
+ }
+ defer func() {
+ unlockCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
+ defer cancel()
+ if _, uerr := conn.Exec(unlockCtx, `SELECT pg_advisory_unlock($1)`, migrationLockKey); uerr != nil {
+ err = errors.Join(err, fmt.Errorf("migration lock: unlock: %w", uerr))
+ }
+ }()
+ return fn()
+}
+
+// acquireMigrationLock polls pg_try_advisory_lock until it wins or ctx ends.
+// Each attempt returns immediately, so between attempts this session is idle —
+// no running statement, no snapshot for a concurrent index build to wait on.
+func acquireMigrationLock(ctx context.Context, conn *pgx.Conn, poll time.Duration) error {
+ ticker := time.NewTicker(poll)
+ defer ticker.Stop()
+ for {
+ var got bool
+ if err := conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1)`, migrationLockKey).Scan(&got); err != nil {
+ if ctx.Err() != nil {
+ return fmt.Errorf("%w: %w", ErrMigrationLockTimeout, ctx.Err())
+ }
+ return fmt.Errorf("migration lock: try: %w", err)
+ }
+ if got {
+ return nil
+ }
+ select {
+ case <-ctx.Done():
+ return fmt.Errorf("%w: %w", ErrMigrationLockTimeout, ctx.Err())
+ case <-ticker.C:
+ }
+ }
+}
diff --git a/internal/platform/db/migratelock_integration_test.go b/internal/platform/db/migratelock_integration_test.go
new file mode 100644
index 00000000..a12b8815
--- /dev/null
+++ b/internal/platform/db/migratelock_integration_test.go
@@ -0,0 +1,120 @@
+//go:build integration
+
+package db_test
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+
+ "github.com/inroad/inroad/internal/platform/db"
+ "github.com/inroad/inroad/internal/platform/db/dbtest"
+)
+
+// The failure this lock exists for, reproduced: several processes migrating the
+// SAME fresh database at once. Without the lock, the migrators waiting in
+// golang-migrate's blocking pg_advisory_lock hold snapshots that the CREATE
+// INDEX CONCURRENTLY in 20260923144758 must wait out, the two waits form a
+// cycle, Postgres aborts the index build, and the schema is left dirty. This is
+// what the integration suite does on a fresh database with -p 4, and what
+// replicas that migrate on boot do on every deploy.
+func TestConcurrentMigratorsOnAFreshDatabaseAllSucceed(t *testing.T) {
+ dsn := dbtest.ScratchDSN(t, "concurrent_migrate")
+ const migrators = 6
+
+ var wg sync.WaitGroup
+ errs := make([]error, migrators)
+ start := make(chan struct{})
+ for i := range migrators {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ <-start
+ errs[i] = db.Migrate(dsn)
+ }()
+ }
+ close(start)
+ wg.Wait()
+ for i, err := range errs {
+ if err != nil {
+ t.Errorf("migrator %d: %v", i, err)
+ }
+ }
+
+ version, dirty, err := db.Version(dsn)
+ if err != nil || dirty || version == 0 {
+ t.Fatalf("after concurrent migrate: version %d dirty %v err %v", version, dirty, err)
+ }
+
+ ctx := context.Background()
+ conn, err := pgx.Connect(ctx, db.WithoutPoolParams(dsn))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = conn.Close(ctx) }()
+ var valid bool
+ if err := conn.QueryRow(ctx, `
+ SELECT i.indisvalid FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+ WHERE c.relname = 'idx_inbox_threads_campaign_contact'`).Scan(&valid); err != nil || !valid {
+ t.Fatalf("CONCURRENTLY index after concurrent migrate: valid=%v err=%v", valid, err)
+ }
+}
+
+// A waiter gives up with db.ErrMigrationLockTimeout rather than hanging or, worse,
+// proceeding without the lock — and while it waits, it holds no running
+// statement for a concurrent index build to deadlock against.
+func TestMigrationLockWaitIsBoundedAndHoldsNoSnapshot(t *testing.T) {
+ dsn := dbtest.ScratchDSN(t, "migrate_lock_wait")
+ ctx := context.Background()
+
+ holder, err := pgx.Connect(ctx, db.WithoutPoolParams(dsn))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = holder.Close(ctx) }()
+ if _, err := holder.Exec(ctx, `SELECT pg_advisory_lock($1)`, db.MigrationLockKey); err != nil {
+ t.Fatal(err)
+ }
+
+ waitCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
+ defer cancel()
+ done := make(chan error, 1)
+ ran := false
+ go func() {
+ done <- db.WithMigrationLockCtx(waitCtx, dsn, 50*time.Millisecond, func() error { ran = true; return nil })
+ }()
+
+ // Mid-wait, no other session on this database is running a statement: the
+ // waiter is idle between polls, which is the whole point.
+ time.Sleep(500 * time.Millisecond)
+ var active int
+ if err := holder.QueryRow(ctx, `
+ SELECT count(*) FROM pg_stat_activity
+ WHERE datname = current_database() AND pid <> pg_backend_pid()
+ AND state = 'active' AND query ILIKE '%advisory_lock%'`).Scan(&active); err != nil {
+ t.Fatal(err)
+ }
+ if active != 0 {
+ t.Errorf("%d session(s) are waiting INSIDE an advisory-lock statement", active)
+ }
+
+ err = <-done
+ if !errors.Is(err, db.ErrMigrationLockTimeout) {
+ t.Fatalf("want db.ErrMigrationLockTimeout, got %v", err)
+ }
+ if ran {
+ t.Fatal("fn ran without the lock")
+ }
+
+ // Once released, the next caller gets it.
+ if _, err := holder.Exec(ctx, `SELECT pg_advisory_unlock($1)`, db.MigrationLockKey); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.WithMigrationLockCtx(ctx, dsn, 50*time.Millisecond, func() error { ran = true; return nil }); err != nil || !ran {
+ t.Fatalf("after release: ran=%v err=%v", ran, err)
+ }
+}
diff --git a/internal/platform/db/migrations/20260923110214_sequence_step_branches.down.sql b/internal/platform/db/migrations/20260923110214_sequence_step_branches.down.sql
new file mode 100644
index 00000000..bb4790a2
--- /dev/null
+++ b/internal/platform/db/migrations/20260923110214_sequence_step_branches.down.sql
@@ -0,0 +1,5 @@
+ALTER TABLE sequence_enrollments DROP COLUMN awaiting_condition_step;
+
+DROP TABLE sequence_step_branches;
+
+ALTER TABLE sequence_steps DROP CONSTRAINT sequence_steps_id_campaign_key;
diff --git a/internal/platform/db/migrations/20260923110214_sequence_step_branches.up.sql b/internal/platform/db/migrations/20260923110214_sequence_step_branches.up.sql
new file mode 100644
index 00000000..06f4bfc1
--- /dev/null
+++ b/internal/platform/db/migrations/20260923110214_sequence_step_branches.up.sql
@@ -0,0 +1,108 @@
+-- Conditional branching for campaign sequences.
+--
+-- A branch is the ROUTER attached to one step: it decides where an enrollment
+-- goes after that step has been sent. A step with no branch row keeps today's
+-- behaviour exactly — it falls through to the next step by step_order — so every
+-- existing linear campaign is a campaign with zero rows here and nothing changes
+-- for it.
+--
+-- One row per source step (step_id is the primary key), rather than a generic
+-- edge table or a "condition" step kind, for three reasons:
+-- * a condition is always evaluated against the step it hangs off ("opened
+-- THIS step within N days"), so tying it to that step makes the reference
+-- point unambiguous and impossible to dangle;
+-- * a non-email node would have to be taught to every send-path query that
+-- reads sequence_steps (content, variants, threading, results); a router row
+-- is invisible to all of them;
+-- * each router has at most two exits (yes/no), so an edge table would only
+-- add a way to express three.
+--
+-- condition = 'always' is the unconditional router: yes_step_id is the next step
+-- (NULL = the path ends here). It is what lets a branch END a path, or jump to a
+-- step other than the next by order, without inventing a special node.
+--
+-- Same-campaign targets are enforced by the database, not only by the service:
+-- every step reference is a composite FK on (id, campaign_id), so a branch cannot
+-- point at another campaign's (or another tenant's) step even if a caller skips
+-- validation. Cycles cannot be expressed as a constraint and are rejected by the
+-- service at save time (internal/platform/seqgraph), with a runtime backstop in
+-- the send path.
+--
+-- The reply-condition index on inbox_threads lives in its own migration
+-- (20260923144758_inbox_threads_campaign_contact_index) so it can be built
+-- CONCURRENTLY, without locking a live table inside this file's transaction.
+
+-- Referenceable by the composite FKs below. Redundant with the primary key for
+-- uniqueness; exists solely to be referenced, the same shape as
+-- campaigns_id_workspace_key (migration 000028).
+ALTER TABLE sequence_steps ADD CONSTRAINT sequence_steps_id_campaign_key UNIQUE (id, campaign_id);
+
+CREATE TABLE sequence_step_branches (
+ step_id UUID NOT NULL PRIMARY KEY,
+ workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ campaign_id UUID NOT NULL,
+ condition TEXT NOT NULL
+ CHECK (condition IN ('always', 'opened', 'clicked', 'replied', 'not_opened', 'not_replied')),
+ -- The evaluation window, in days after the source step's send. Required for
+ -- every real condition, forbidden on 'always' (see the CHECK below).
+ within_days INT CHECK (within_days BETWEEN 1 AND 90),
+ -- Optional narrowing of a reply condition to one reply label, by its stable
+ -- key (the value inbox_messages.reply_class and sequence_enrollments.
+ -- reply_class already store). A key rather than an FK to reply_labels for the
+ -- reason migration 000047 gives for reply_class: a label may be deleted, and
+ -- the branch must degrade to "never matches" rather than vanish.
+ reply_label_key TEXT CHECK (reply_label_key ~ '^[a-z][a-z0-9_]{0,63}$'),
+ -- NULL on either exit means that exit ends the path (the enrollment
+ -- completes). Deleting a target step therefore turns its incoming exits into
+ -- ends rather than blocking the delete or orphaning the reference.
+ yes_step_id UUID,
+ no_step_id UUID,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+
+ CONSTRAINT sequence_step_branches_step_fkey
+ FOREIGN KEY (step_id, campaign_id) REFERENCES sequence_steps (id, campaign_id) ON DELETE CASCADE,
+ CONSTRAINT sequence_step_branches_campaign_workspace_fkey
+ FOREIGN KEY (campaign_id, workspace_id) REFERENCES campaigns (id, workspace_id) ON DELETE CASCADE,
+ -- SET NULL (col) nulls only the target column: a plain SET NULL would also
+ -- null campaign_id, which is NOT NULL and shared with the other references.
+ -- Column-list SET NULL is PostgreSQL 15+; every supported deployment runs 16.
+ CONSTRAINT sequence_step_branches_yes_fkey
+ FOREIGN KEY (yes_step_id, campaign_id) REFERENCES sequence_steps (id, campaign_id)
+ ON DELETE SET NULL (yes_step_id),
+ CONSTRAINT sequence_step_branches_no_fkey
+ FOREIGN KEY (no_step_id, campaign_id) REFERENCES sequence_steps (id, campaign_id)
+ ON DELETE SET NULL (no_step_id),
+
+ -- A real condition has a window; 'always' has none.
+ CONSTRAINT sequence_step_branches_window_chk
+ CHECK ((condition = 'always') = (within_days IS NULL)),
+ -- 'always' has exactly one exit.
+ CONSTRAINT sequence_step_branches_always_single_exit_chk
+ CHECK (condition <> 'always' OR no_step_id IS NULL),
+ -- A label only narrows a REPLY condition.
+ CONSTRAINT sequence_step_branches_label_chk
+ CHECK (reply_label_key IS NULL OR condition IN ('replied', 'not_replied')),
+ -- A self-reference is the smallest cycle; refused here as well as in the service.
+ CONSTRAINT sequence_step_branches_no_self_chk
+ CHECK (yes_step_id IS DISTINCT FROM step_id AND no_step_id IS DISTINCT FROM step_id)
+);
+
+-- The send path loads a campaign's routers in one read (ListBranchesByCampaign).
+CREATE INDEX idx_sequence_step_branches_campaign ON sequence_step_branches (campaign_id, workspace_id);
+-- ON DELETE SET NULL of a step looks up the rows referencing it; without these a
+-- step delete would scan the table (same reasoning as migration 000030).
+CREATE INDEX idx_sequence_step_branches_yes ON sequence_step_branches (yes_step_id) WHERE yes_step_id IS NOT NULL;
+CREATE INDEX idx_sequence_step_branches_no ON sequence_step_branches (no_step_id) WHERE no_step_id IS NOT NULL;
+
+-- The current_step at which this enrollment last waited on a branch condition.
+--
+-- It exists for ONE decision: whether the send path must re-check a step's due
+-- time instead of trusting the advance task's schedule. A linear enrollment's
+-- advance task is scheduled for exactly the step's due time, so today's path
+-- trusts it — but a task scheduled by a condition re-check is not, and if the
+-- operator deletes the campaign's last branch while a contact is waiting, that
+-- task would otherwise send the linear successor at the re-check time, days
+-- ahead of its delay. Stale by construction once current_step moves on, so the
+-- cursor advance never has to clear it.
+ALTER TABLE sequence_enrollments ADD COLUMN awaiting_condition_step INT;
diff --git a/internal/platform/db/migrations/20260923144758_inbox_threads_campaign_contact_index.down.sql b/internal/platform/db/migrations/20260923144758_inbox_threads_campaign_contact_index.down.sql
new file mode 100644
index 00000000..f3e60555
--- /dev/null
+++ b/internal/platform/db/migrations/20260923144758_inbox_threads_campaign_contact_index.down.sql
@@ -0,0 +1,2 @@
+-- One statement, for the reason the up file gives.
+DROP INDEX CONCURRENTLY IF EXISTS idx_inbox_threads_campaign_contact;
diff --git a/internal/platform/db/migrations/20260923144758_inbox_threads_campaign_contact_index.up.sql b/internal/platform/db/migrations/20260923144758_inbox_threads_campaign_contact_index.up.sql
new file mode 100644
index 00000000..e4009cf8
--- /dev/null
+++ b/internal/platform/db/migrations/20260923144758_inbox_threads_campaign_contact_index.up.sql
@@ -0,0 +1,18 @@
+-- Reply conditions on branched sequences (20260923110214_sequence_step_branches)
+-- look for an inbound message on an enrollment's campaign and contact. Every
+-- earlier inbox_threads index leads with workspace_id and a mailbox or a sort key,
+-- so that lookup would otherwise walk every thread in the workspace, once per
+-- waiting enrollment per re-check.
+--
+-- CONCURRENTLY, so the build takes no lock that blocks inbox writes on a live
+-- table. That is only possible because this file is ONE statement: the pgx/v5
+-- golang-migrate driver sends a file as a single simple-protocol query, which
+-- runs a lone statement outside any transaction block but wraps several in an
+-- implicit one — where CREATE INDEX CONCURRENTLY is refused (see
+-- 20260827185855_inbox_messages_mailbox_id). Do not add a second statement here.
+--
+-- A concurrent build that fails leaves an INVALID index behind rather than
+-- rolling back; IF NOT EXISTS would then skip it on a re-run. The integration
+-- test TestInboxThreadsCampaignContactIndexIsValid asserts pg_index.indisvalid.
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_inbox_threads_campaign_contact
+ ON inbox_threads (campaign_id, contact_id) WHERE campaign_id IS NOT NULL;
diff --git a/internal/platform/db/migrations/20260923161044_step_branch_updated_at_advances.down.sql b/internal/platform/db/migrations/20260923161044_step_branch_updated_at_advances.down.sql
new file mode 100644
index 00000000..5bec8975
--- /dev/null
+++ b/internal/platform/db/migrations/20260923161044_step_branch_updated_at_advances.down.sql
@@ -0,0 +1,2 @@
+DROP TRIGGER sequence_step_branches_advance_updated_at ON sequence_step_branches;
+DROP FUNCTION sequence_step_branches_advance_updated_at();
diff --git a/internal/platform/db/migrations/20260923161044_step_branch_updated_at_advances.up.sql b/internal/platform/db/migrations/20260923161044_step_branch_updated_at_advances.up.sql
new file mode 100644
index 00000000..d2e93895
--- /dev/null
+++ b/internal/platform/db/migrations/20260923161044_step_branch_updated_at_advances.up.sql
@@ -0,0 +1,36 @@
+-- sequence_step_branches.updated_at becomes the branch's concurrency token: a
+-- PUT/DELETE may carry expected_updated_at and applies only if the stored value
+-- still equals it (internal/app/sequencestep/branchstore.go). A token has to
+-- change on EVERY write, and a plain `updated_at = now()` in the upsert does not
+-- guarantee that:
+-- * now() is the TRANSACTION start, taken before the graph lock is acquired,
+-- so a transaction that began first but got the lock second writes an OLDER
+-- value than the one it replaced;
+-- * two writes can share a microsecond, or the clock can step backwards, and
+-- the token would then repeat — a stale client's precondition would match;
+-- * ON DELETE SET NULL (yes_step_id / no_step_id) rewrites a row when its
+-- target step is deleted, and no query of ours runs to bump the column.
+--
+-- So the database owns the column, for every insert and every update including
+-- the referential action: an insert takes clock_timestamp() (the statement's
+-- real time, not the transaction's), and an update takes the later of
+-- clock_timestamp() and one microsecond past the old value — strictly advancing
+-- per row regardless of the clock. A timestamp rather than a version integer so
+-- a branch deleted and re-created does not restart at a value a stale client
+-- still holds, and so the already-served updated_at field is the token rather
+-- than a second field saying the same thing.
+CREATE FUNCTION sequence_step_branches_advance_updated_at() RETURNS trigger
+LANGUAGE plpgsql AS $$
+BEGIN
+ IF TG_OP = 'UPDATE' THEN
+ NEW.updated_at := GREATEST(clock_timestamp(), OLD.updated_at + interval '1 microsecond');
+ ELSE
+ NEW.updated_at := clock_timestamp();
+ END IF;
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER sequence_step_branches_advance_updated_at
+BEFORE INSERT OR UPDATE ON sequence_step_branches
+FOR EACH ROW EXECUTE FUNCTION sequence_step_branches_advance_updated_at();
diff --git a/internal/platform/db/queries/enrollment.sql b/internal/platform/db/queries/enrollment.sql
index 62857510..0360dca9 100644
--- a/internal/platform/db/queries/enrollment.sql
+++ b/internal/platform/db/queries/enrollment.sql
@@ -134,3 +134,53 @@ WHERE status = 'active' AND next_due_at IS NOT NULL
AND next_due_at < now() - interval '5 minutes'
ORDER BY next_due_at ASC
LIMIT 500;
+
+-- name: FinishEnrollment :exec
+-- A branch routed this enrollment to the end of its path WITHOUT a send: mark it
+-- completed and drop it out of the due index. Unlike CompleteEnrollment it leaves
+-- current_step and last_sent_at alone, because nothing was sent — they still
+-- describe the last message the contact actually received. Guarded on
+-- status='active' for the reason CompleteEnrollment is: a stop is terminal and wins.
+UPDATE sequence_enrollments
+SET status = 'completed', completed_at = now(), next_due_at = NULL
+WHERE id = $1 AND workspace_id = $2 AND status = 'active';
+
+-- name: AwaitEnrollmentCondition :exec
+-- A branch condition on the enrollment's current step is still open (or its
+-- routed step is not due yet): record when to look again, and that this wait is
+-- governed by a condition at this step (see awaiting_condition_step in migration
+-- 20260923110214). Stamping next_due_at keeps the sweeper from re-driving the
+-- enrollment every tick while it legitimately waits. Guarded on status='active'.
+UPDATE sequence_enrollments
+SET next_due_at = $3, awaiting_condition_step = current_step
+WHERE id = $1 AND workspace_id = $2 AND status = 'active';
+
+-- name: NudgeEnrollmentAwaitingReply :exec
+-- A reply that did NOT stop the enrollment just arrived. If the enrollment is
+-- waiting on a reply condition at its current step, pull its due time to now so
+-- the next sweep evaluates the condition instead of waiting out the re-check
+-- interval. Automated replies (out-of-office, auto-reply) are excluded: they are
+-- the only replies that defer an enrollment, and pulling next_due_at forward
+-- would undo that deferral. Never pushes a due time LATER, and is a no-op for
+-- every enrollment without a reply condition — which is every linear campaign.
+--
+-- awaiting_condition_step = current_step is the second half of that deferral
+-- guard: it proves the due time being pulled forward was stamped by a CONDITION
+-- wait at this step. An out-of-office deferral is stamped by DeferEnrollment,
+-- which never touches that column, so an enrollment whose current due time is a
+-- stated absence (not yet parked by a condition at this step) is left alone.
+UPDATE sequence_enrollments e
+SET next_due_at = now()
+WHERE e.id = $1 AND e.workspace_id = $2 AND e.status = 'active'
+ AND e.next_due_at > now()
+ AND e.awaiting_condition_step = e.current_step
+ AND NOT COALESCE(
+ (SELECT rl.is_automated FROM reply_labels rl
+ WHERE rl.workspace_id = e.workspace_id AND rl.key = sqlc.arg(reply_class)::text),
+ sqlc.arg(reply_class)::text IN ('auto_reply', 'out_of_office'))
+ AND EXISTS (
+ SELECT 1 FROM sequence_step_branches b
+ JOIN sequence_steps s ON s.id = b.step_id AND s.campaign_id = b.campaign_id
+ WHERE b.campaign_id = e.campaign_id AND b.workspace_id = e.workspace_id
+ AND s.step_order = e.current_step
+ AND b.condition IN ('replied', 'not_replied'));
diff --git a/internal/platform/db/queries/stepbranch.sql b/internal/platform/db/queries/stepbranch.sql
new file mode 100644
index 00000000..9c095f10
--- /dev/null
+++ b/internal/platform/db/queries/stepbranch.sql
@@ -0,0 +1,141 @@
+-- name: ListBranchesByCampaign :many
+-- Every router in the campaign, workspace-pinned. The send path reads this once
+-- per advance: an empty result is the linear campaign, which then takes exactly
+-- the pre-branching code path.
+SELECT * FROM sequence_step_branches
+WHERE campaign_id = $1 AND workspace_id = $2
+ORDER BY step_id;
+
+-- name: UpsertBranch :one
+-- Create or replace the router on one step. workspace_id and campaign_id are
+-- written from the caller's pinned values, and the composite FKs refuse a step
+-- (source or target) that is not in that campaign, so a foreign id cannot be
+-- smuggled in even if the service's own check were skipped. The ON CONFLICT
+-- update is pinned on workspace_id as well: a step id belonging to another tenant
+-- updates nothing and returns no row. updated_at is not written here: a trigger
+-- advances it on every insert and update (migration
+-- 20260923161044_step_branch_updated_at_advances), because it is the branch's
+-- concurrency token.
+INSERT INTO sequence_step_branches (step_id, workspace_id, campaign_id, condition, within_days,
+ reply_label_key, yes_step_id, no_step_id)
+VALUES ($1, $2, $3, $4, sqlc.narg(within_days), sqlc.narg(reply_label_key),
+ sqlc.narg(yes_step_id), sqlc.narg(no_step_id))
+ON CONFLICT (step_id) DO UPDATE
+SET condition = EXCLUDED.condition, within_days = EXCLUDED.within_days,
+ reply_label_key = EXCLUDED.reply_label_key, yes_step_id = EXCLUDED.yes_step_id,
+ no_step_id = EXCLUDED.no_step_id
+WHERE sequence_step_branches.workspace_id = EXCLUDED.workspace_id
+ AND sequence_step_branches.campaign_id = EXCLUDED.campaign_id
+RETURNING *;
+
+-- name: InsertBranchIfAbsent :one
+-- Create the router on one step only if the step has none: the "expect no
+-- branch" precondition. No row returned means one already exists (the caller
+-- reads it with GetBranch to report it). Atomic on its own via the primary key,
+-- so two concurrent creates cannot both succeed even without the graph lock.
+INSERT INTO sequence_step_branches (step_id, workspace_id, campaign_id, condition, within_days,
+ reply_label_key, yes_step_id, no_step_id)
+VALUES ($1, $2, $3, $4, sqlc.narg(within_days), sqlc.narg(reply_label_key),
+ sqlc.narg(yes_step_id), sqlc.narg(no_step_id))
+ON CONFLICT (step_id) DO NOTHING
+RETURNING *;
+
+-- name: UpdateBranchIfUnchanged :one
+-- Replace the router on one step only if its updated_at still equals the value
+-- the client last read: the "expect this version" precondition. No row returned
+-- means the branch changed or is gone. Compared at full (microsecond) precision;
+-- the trigger guarantees every write moves the value. Pinned on workspace_id and
+-- campaign_id.
+UPDATE sequence_step_branches
+SET condition = sqlc.arg(condition), within_days = sqlc.narg(within_days),
+ reply_label_key = sqlc.narg(reply_label_key), yes_step_id = sqlc.narg(yes_step_id),
+ no_step_id = sqlc.narg(no_step_id)
+WHERE step_id = sqlc.arg(step_id) AND workspace_id = sqlc.arg(workspace_id)
+ AND campaign_id = sqlc.arg(campaign_id)
+ AND updated_at = sqlc.arg(expected_updated_at)::timestamptz
+RETURNING *;
+
+-- name: GetBranch :one
+-- One step's router, workspace- and campaign-pinned. Read inside a refused
+-- precondition's transaction, so the 409 carries the branch that won.
+SELECT * FROM sequence_step_branches
+WHERE step_id = $1 AND campaign_id = $2 AND workspace_id = $3;
+
+-- name: DeleteBranch :exec
+-- Remove a step's router, returning it to linear fall-through. Pinned on
+-- workspace_id and campaign_id.
+DELETE FROM sequence_step_branches
+WHERE step_id = $1 AND campaign_id = $2 AND workspace_id = $3;
+
+-- name: DeleteBranchIfUnchanged :execrows
+-- Remove a step's router only if its updated_at still equals the value the
+-- client last read. Zero rows means it changed or was already removed.
+DELETE FROM sequence_step_branches
+WHERE step_id = $1 AND campaign_id = $2 AND workspace_id = $3
+ AND updated_at = sqlc.arg(expected_updated_at)::timestamptz;
+
+-- name: LockCampaignGraph :one
+-- Serializes every write that changes a campaign's routing graph (branch
+-- upsert/delete, step delete, reorder), so the save-time cycle check sees the
+-- graph it is actually committing into — two edits that are each acyclic alone
+-- can form a cycle together. FOR NO KEY UPDATE rather than FOR UPDATE: it still
+-- conflicts with itself, but not with the FOR KEY SHARE lock every sends insert
+-- takes on its campaign FK, so a graph edit never stalls delivery.
+SELECT id FROM campaigns WHERE id = $1 AND workspace_id = $2 FOR NO KEY UPDATE;
+
+-- name: ReplyLabelStopsEnrollment :one
+-- Whether the workspace's reply label with this key stops the enrollment.
+-- Save-time validation only: no row (pgx.ErrNoRows) means the label does not
+-- exist and a branch naming it could never match; true means a reply with that
+-- label stops the sequence before any branch can route it, so a branch naming it
+-- could never fire either.
+SELECT stops_enrollment FROM reply_labels WHERE workspace_id = $1 AND key = $2;
+
+-- name: CampaignTrackingEnabled :one
+-- Whether the campaign rewrites links and embeds the open pixel. Save-time
+-- validation for open/click branches, which have no evidence to read otherwise.
+SELECT tracking_enabled FROM campaigns WHERE id = $1 AND workspace_id = $2;
+
+-- name: FirstHumanTrackingEventAt :one
+-- The earliest HUMAN open or click of one send, at or before window_end. It reads
+-- the stored bot verdict (NOT is_machine) — the same definition CountHumanOpens
+-- reports — rather than deriving its own, so a branch and the open rate can
+-- never disagree about the same contact (docs/security.md invariant 84). Served
+-- by idx_tracking_send_recent (send_id, kind, is_machine, created_at).
+SELECT created_at FROM tracking_events
+WHERE send_id = $1 AND workspace_id = $2 AND kind = $3 AND NOT is_machine
+ AND created_at <= sqlc.arg(window_end)::timestamptz
+ORDER BY created_at ASC
+LIMIT 1;
+
+-- name: FirstInboundReplyAt :one
+-- The earliest inbound reply from this contact on this campaign that arrived
+-- inside [window_start, window_end]. created_at (when WE ingested it), not
+-- occurred_at: the latter is the sender's own Date header, which they control.
+--
+-- label_key '' means "any human reply": a message whose label is automated
+-- (out-of-office, auto-reply) is not a reply from a person and does not count. A
+-- label the workspace has since deleted falls back to the builtin automated keys,
+-- the same degradation the inbox dispatch applies. A non-empty label_key matches
+-- that key exactly, automated or not — branching on an out-of-office is a
+-- legitimate thing to want.
+SELECT m.created_at FROM inbox_messages m
+JOIN inbox_threads t ON t.id = m.thread_id AND t.workspace_id = m.workspace_id
+LEFT JOIN reply_labels rl ON rl.workspace_id = m.workspace_id AND rl.key = m.reply_class
+WHERE m.workspace_id = $1 AND t.campaign_id = $2 AND t.contact_id = $3
+ AND m.direction = 'inbound'
+ AND m.created_at >= sqlc.arg(window_start)::timestamptz
+ AND m.created_at <= sqlc.arg(window_end)::timestamptz
+ AND CASE WHEN sqlc.arg(label_key)::text = ''
+ THEN NOT COALESCE(rl.is_automated, m.reply_class IN ('auto_reply', 'out_of_office'))
+ ELSE m.reply_class = sqlc.arg(label_key)::text
+ END
+ORDER BY m.created_at ASC
+LIMIT 1;
+
+-- name: StepSendCreatedAt :one
+-- When a (deterministically-id'd) step send row was first created. The send
+-- path's cycle backstop compares the routed step's row with the CURRENT step's
+-- own row: a routed step created before the step the contact is on was visited
+-- EARLIER on this path, i.e. the graph now loops. Workspace-pinned.
+SELECT created_at FROM sends WHERE id = $1 AND workspace_id = $2;
diff --git a/internal/platform/db/queries/stepsend.sql b/internal/platform/db/queries/stepsend.sql
index 6e33e930..0d9028b5 100644
--- a/internal/platform/db/queries/stepsend.sql
+++ b/internal/platform/db/queries/stepsend.sql
@@ -18,6 +18,7 @@
-- the next advance.
SELECT e.id AS enrollment_id, e.workspace_id, e.contact_id, e.current_step,
e.status, e.thread_root_id, e.next_due_at, e.mailbox_id AS enrollment_mailbox_id,
+ e.last_sent_at, e.awaiting_condition_step,
cam.id AS campaign_id, cam.rotation_mode, cam.tracking_enabled, cam.timezone,
cam.daily_limit, cam.max_new_leads_per_day, cam.status AS campaign_status,
ct.email AS to_email, ct.first_name, ct.last_name, ct.company, ct.custom_fields,
@@ -96,7 +97,17 @@ SELECT COALESCE(sqlc.narg(not_due_until)::timestamptz > now(), false)::bool AS n
-- name: LatestSentForContact :one
-- The most recent successfully-sent step for a (campaign, contact), used to
-- thread the next step (In-Reply-To = its message_id; References = its chain).
+--
+-- "Most recent" is by sent_at, with step_order only as the tie-break. On a linear
+-- sequence the two orders are the same (steps send in step_order), but a branched
+-- path need not visit steps in step_order — 1 → 3 → 2 is a valid path — and
+-- threading onto the highest-numbered step would reply to a message that is not
+-- the latest one the contact received.
+--
+-- Workspace-pinned like every other tenant read, even though (campaign_id,
+-- contact_id) already came from a workspace-scoped bundle: the pin costs nothing
+-- and keeps this from depending on its caller's discipline.
SELECT message_id, references_header FROM sends
-WHERE campaign_id = $1 AND contact_id = $2 AND status = 'sent'
-ORDER BY step_order DESC
+WHERE campaign_id = $1 AND contact_id = $2 AND workspace_id = $3 AND status = 'sent'
+ORDER BY sent_at DESC NULLS LAST, step_order DESC
LIMIT 1;
diff --git a/internal/platform/db/tenancyqueries_test.go b/internal/platform/db/tenancyqueries_test.go
index 9b33b819..2d161d43 100644
--- a/internal/platform/db/tenancyqueries_test.go
+++ b/internal/platform/db/tenancyqueries_test.go
@@ -113,7 +113,6 @@ var tenancyExceptions = map[string]string{
"oauth_provider.sql:RevokeOauthRefreshFamily": "revokes a whole rotation family on reuse detection; pinned to family_id, which is narrower than a workspace.",
"mailbox.sql:MailboxExists": "an existence probe by mailbox id returning only a boolean — it can leak at most whether an unguessable UUID is an active mailbox, never row content.",
"send.sql:CountSentToday": "the daily-cap gate, keyed on an unguessable mailbox id and returning only a count. Callers reach it having already resolved the mailbox within their workspace.",
- "stepsend.sql:LatestSentForContact": "threading headers for the next step, keyed on (campaign_id, contact_id); both were resolved workspace-scoped by the caller that scheduled this step.",
"tracking.sql:GetSendTrackingContext": "the public tracking pixel path has NO authenticated principal to scope by — the send id arrives in an HMAC-signed token. Returns a verdict about that one send, never row data; scoping it would mean trusting a workspace id from an unauthenticated request.",
"tracking.sql:CountRecentSendOpensFromSubnet": "same unauthenticated tracking path as GetSendTrackingContext; returns a count about one send.",
@@ -465,7 +464,7 @@ func TestEveryTenancyExceptionHasAWrittenReason(t *testing.T) {
// this guard has stopped guarding, so the count is the size of the hole in the net.
// Raising it should be a conscious act in a diff, not a drift.
func TestTheTenancyAllowlistDoesNotGrowSilently(t *testing.T) {
- const known = 50
+ const known = 49
if got := len(tenancyExceptions); got != known {
t.Errorf("tenancyExceptions has %d entries, expected %d. Every entry is a query this "+
"guard no longer checks. If you added one deliberately, update `known` in the same "+
diff --git a/internal/platform/seqgraph/seqgraph.go b/internal/platform/seqgraph/seqgraph.go
new file mode 100644
index 00000000..2f36b106
--- /dev/null
+++ b/internal/platform/seqgraph/seqgraph.go
@@ -0,0 +1,498 @@
+// Package seqgraph is the pure routing model of a branched campaign sequence:
+// which step an enrollment goes to after the one it just received, whether a
+// condition on that step has been decided yet, and whether a proposed graph is
+// safe to save.
+//
+// It does no I/O. The control plane loads steps, branches and the engagement
+// evidence, and asks this package what they mean; the sequence-step service asks
+// it whether an edit may be committed. Keeping both callers on one definition is
+// the point — a cycle rule that the save path and the send path disagreed on
+// would let a graph be saved that the send path then loops on.
+//
+// # The model
+//
+// Every step is a node. A step's exits are:
+// - its Branch, when it has one: 'always' has one exit (Yes), every real
+// condition has two (Yes, No). An unset exit (uuid.Nil) ends the path.
+// - otherwise the next step by step_order — the linear fall-through every
+// campaign had before branching existed, so a campaign with no branches is
+// exactly the linear sequence.
+//
+// The fall-through is a real edge for cycle detection: 1 -> 2 -> 3 with a single
+// explicit 3 -> 1 is a cycle even though only one edge was drawn.
+package seqgraph
+
+import (
+ "errors"
+ "fmt"
+ "regexp"
+ "slices"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// Condition is what a branch tests. Values are the stored
+// sequence_step_branches.condition strings and the API enum.
+type Condition string
+
+const (
+ // Always routes unconditionally to Yes (uuid.Nil = end the path). It is how
+ // a path ends early or jumps somewhere other than the next step by order.
+ Always Condition = "always"
+ Opened Condition = "opened"
+ Clicked Condition = "clicked"
+ Replied Condition = "replied"
+ NotOpened Condition = "not_opened"
+ NotReplied Condition = "not_replied"
+)
+
+// ParseCondition maps a stored/API string onto a known condition.
+func ParseCondition(s string) (Condition, bool) {
+ c := Condition(s)
+ switch c {
+ case Always, Opened, Clicked, Replied, NotOpened, NotReplied:
+ return c, true
+ }
+ return "", false
+}
+
+// Signal is the engagement a condition watches.
+type Signal int
+
+const (
+ SignalNone Signal = iota
+ SignalOpen
+ SignalClick
+ SignalReply
+)
+
+// Signal reports which engagement evidence decides c.
+func (c Condition) Signal() Signal {
+ switch c {
+ case Opened, NotOpened:
+ return SignalOpen
+ case Clicked:
+ return SignalClick
+ case Replied, NotReplied:
+ return SignalReply
+ }
+ return SignalNone
+}
+
+// negated reports whether the condition is satisfied by the ABSENCE of its
+// signal, so that seeing the signal decides it false.
+func (c Condition) negated() bool { return c == NotOpened || c == NotReplied }
+
+// Window bounds, in days. A day minimum because every engagement signal here is
+// human-paced; 90 because a sequence waiting a quarter for one open is almost
+// certainly a mis-set value, and a bounded window is what guarantees every wait
+// ends.
+const (
+ MinWithinDays = 1
+ MaxWithinDays = 90
+)
+
+// Step is one node: the step's id, its position, and its delay (the minimum gap
+// between the decision that routes to it and its send).
+type Step struct {
+ ID uuid.UUID
+ Order int32
+ DelaySeconds int32
+}
+
+// Branch is the router on one step.
+type Branch struct {
+ StepID uuid.UUID
+ Condition Condition
+ WithinDays int
+ // ReplyLabelKey narrows a reply condition to one reply label ("" = any
+ // human reply).
+ ReplyLabelKey string
+ // Yes and No are the exits; uuid.Nil ends the path. 'always' uses Yes only.
+ Yes uuid.UUID
+ No uuid.UUID
+}
+
+// Window is how long after the source step's send the condition watches.
+func (b Branch) Window() time.Duration {
+ return time.Duration(b.WithinDays) * 24 * time.Hour
+}
+
+// Target is the exit a decided verdict takes (uuid.Nil = end).
+func (b Branch) Target(v Verdict) uuid.UUID {
+ if v.Yes {
+ return b.Yes
+ }
+ return b.No
+}
+
+// exits lists the branch's set exits, Yes first.
+func (b Branch) exits() []uuid.UUID {
+ var out []uuid.UUID
+ for _, id := range []uuid.UUID{b.Yes, b.No} {
+ if id != uuid.Nil {
+ out = append(out, id)
+ }
+ }
+ return out
+}
+
+// Validation codes. Stable strings: the API returns them as `code` so a client
+// can react to the failure without parsing the message.
+const (
+ CodeInvalidCondition = "invalid_condition"
+ CodeInvalidWithinDays = "invalid_within_days"
+ CodeLabelNotAllowed = "invalid_reply_label"
+ // CodeLabelStopsSequence is a reply label whose replies stop the enrollment,
+ // so a branch naming it could never route anyone.
+ CodeLabelStopsSequence = "reply_label_stops_sequence"
+ // CodeTrackingRequired is an open/click condition on a campaign or step that
+ // can never record an open or click.
+ CodeTrackingRequired = "tracking_required"
+ CodeNoExitNotAllowed = "no_exit_not_allowed"
+ CodeUnknownStep = "unknown_step"
+ CodeUnknownTarget = "unknown_target"
+ CodeCycle = "cycle"
+)
+
+// ShapeError is a branch that is malformed on its own, before any graph is
+// considered.
+type ShapeError struct {
+ Code string
+ Msg string
+}
+
+func (e *ShapeError) Error() string { return e.Msg }
+
+// TargetError is an exit pointing at a step that is not in the campaign.
+type TargetError struct {
+ StepID uuid.UUID
+ Target uuid.UUID
+}
+
+func (e *TargetError) Error() string {
+ return fmt.Sprintf("branch on step %s points at %s, which is not a step of this campaign", e.StepID, e.Target)
+}
+
+// UnknownStepError is a branch whose source step is not in the campaign.
+type UnknownStepError struct{ StepID uuid.UUID }
+
+func (e *UnknownStepError) Error() string {
+ return fmt.Sprintf("step %s is not a step of this campaign", e.StepID)
+}
+
+// CycleError is a graph in which some path revisits a step. StepIDs is the loop,
+// in path order, starting at the step the loop returns to.
+type CycleError struct{ StepIDs []uuid.UUID }
+
+func (e *CycleError) Error() string {
+ return fmt.Sprintf("the sequence would loop through %d step(s); every path must end", len(e.StepIDs))
+}
+
+// CodeOf returns the validation code carried by err, or "" for an error that is
+// not a validation failure.
+func CodeOf(err error) string {
+ var shape *ShapeError
+ var target *TargetError
+ var unknown *UnknownStepError
+ var cycle *CycleError
+ switch {
+ case errors.As(err, &shape):
+ return shape.Code
+ case errors.As(err, &target):
+ return CodeUnknownTarget
+ case errors.As(err, &unknown):
+ return CodeUnknownStep
+ case errors.As(err, &cycle):
+ return CodeCycle
+ }
+ return ""
+}
+
+// labelKeyPattern mirrors reply_labels.key's CHECK (migration 000047), so a key
+// that could never name a label is refused at the boundary rather than by the
+// database.
+var labelKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
+
+// ValidateShape checks the branch on its own: a known condition, a window
+// exactly when one is meaningful, a label only on a reply condition, one exit on
+// 'always', and no exit back to itself.
+func (b Branch) ValidateShape() error {
+ if _, ok := ParseCondition(string(b.Condition)); !ok {
+ return &ShapeError{Code: CodeInvalidCondition, Msg: fmt.Sprintf("unknown condition %q", b.Condition)}
+ }
+ if b.Condition == Always {
+ if b.WithinDays != 0 {
+ return &ShapeError{Code: CodeInvalidWithinDays, Msg: "within_days is not allowed on an 'always' branch"}
+ }
+ if b.No != uuid.Nil {
+ return &ShapeError{Code: CodeNoExitNotAllowed, Msg: "an 'always' branch has only a yes exit"}
+ }
+ } else if b.WithinDays < MinWithinDays || b.WithinDays > MaxWithinDays {
+ return &ShapeError{Code: CodeInvalidWithinDays,
+ Msg: fmt.Sprintf("within_days must be between %d and %d", MinWithinDays, MaxWithinDays)}
+ }
+ if b.ReplyLabelKey != "" {
+ if b.Condition.Signal() != SignalReply {
+ return &ShapeError{Code: CodeLabelNotAllowed, Msg: "reply_label_key is only allowed on a replied/not_replied branch"}
+ }
+ if !labelKeyPattern.MatchString(b.ReplyLabelKey) {
+ return &ShapeError{Code: CodeLabelNotAllowed, Msg: "reply_label_key is not a valid label key"}
+ }
+ }
+ if b.Yes == b.StepID || b.No == b.StepID {
+ return &CycleError{StepIDs: []uuid.UUID{b.StepID}}
+ }
+ return nil
+}
+
+// Graph is one campaign's steps and branches.
+type Graph struct {
+ ordered []Step // by Order ascending
+ byID map[uuid.UUID]Step
+ branches map[uuid.UUID]Branch
+ // branchOrder keeps validation deterministic: the order branches were given.
+ branchOrder []uuid.UUID
+}
+
+// New builds a graph. Steps may arrive in any order; step_order gaps (left by a
+// delete, which does not renumber) are tolerated exactly as the linear send path
+// tolerates them.
+func New(steps []Step, branches []Branch) Graph {
+ g := Graph{
+ ordered: slices.Clone(steps),
+ byID: make(map[uuid.UUID]Step, len(steps)),
+ branches: make(map[uuid.UUID]Branch, len(branches)),
+ }
+ slices.SortFunc(g.ordered, func(a, b Step) int { return int(a.Order) - int(b.Order) })
+ for _, s := range g.ordered {
+ g.byID[s.ID] = s
+ }
+ for _, b := range branches {
+ if _, dup := g.branches[b.StepID]; !dup {
+ g.branchOrder = append(g.branchOrder, b.StepID)
+ }
+ g.branches[b.StepID] = b
+ }
+ return g
+}
+
+// HasBranches reports whether any step has a router. A graph without one is the
+// linear sequence.
+func (g Graph) HasBranches() bool { return len(g.branches) > 0 }
+
+// Entry is the first step (lowest step_order), where every enrollment starts.
+func (g Graph) Entry() (Step, bool) {
+ if len(g.ordered) == 0 {
+ return Step{}, false
+ }
+ return g.ordered[0], true
+}
+
+// Step looks a step up by id.
+func (g Graph) Step(id uuid.UUID) (Step, bool) {
+ s, ok := g.byID[id]
+ return s, ok
+}
+
+// StepByOrder looks a step up by its step_order — the enrollment cursor's unit.
+func (g Graph) StepByOrder(order int32) (Step, bool) {
+ i, found := slices.BinarySearchFunc(g.ordered, order, func(s Step, o int32) int { return int(s.Order) - int(o) })
+ if !found {
+ return Step{}, false
+ }
+ return g.ordered[i], true
+}
+
+// Branch returns the router on a step, if it has one.
+func (g Graph) Branch(stepID uuid.UUID) (Branch, bool) {
+ b, ok := g.branches[stepID]
+ return b, ok
+}
+
+// DefaultNext is the linear fall-through: the next step by step_order.
+func (g Graph) DefaultNext(stepID uuid.UUID) (Step, bool) {
+ s, ok := g.byID[stepID]
+ if !ok {
+ return Step{}, false
+ }
+ i, _ := slices.BinarySearchFunc(g.ordered, s.Order+1, func(x Step, o int32) int { return int(x.Order) - int(o) })
+ if i >= len(g.ordered) {
+ return Step{}, false
+ }
+ return g.ordered[i], true
+}
+
+// exits lists a step's effective successors: its branch's set exits, or the
+// linear fall-through when it has no branch.
+func (g Graph) exits(stepID uuid.UUID) []uuid.UUID {
+ if b, ok := g.branches[stepID]; ok {
+ return b.exits()
+ }
+ if n, ok := g.DefaultNext(stepID); ok {
+ return []uuid.UUID{n.ID}
+ }
+ return nil
+}
+
+// Validate checks every branch's shape, that every branch and exit names a step
+// of this campaign, and that no path revisits a step. The first problem found is
+// returned; the order is deterministic (branches as given, then steps by order).
+func (g Graph) Validate() error {
+ for _, id := range g.branchOrder {
+ b := g.branches[id]
+ if err := b.ValidateShape(); err != nil {
+ return err
+ }
+ if _, ok := g.byID[b.StepID]; !ok {
+ return &UnknownStepError{StepID: b.StepID}
+ }
+ for _, t := range b.exits() {
+ if _, ok := g.byID[t]; !ok {
+ return &TargetError{StepID: b.StepID, Target: t}
+ }
+ }
+ }
+ return g.findCycle()
+}
+
+// findCycle is a three-colour depth-first search over the effective edges,
+// iterative so a long sequence cannot grow the goroutine stack without bound.
+func (g Graph) findCycle() error {
+ const (
+ white = iota
+ grey
+ black
+ )
+ colour := make(map[uuid.UUID]int, len(g.ordered))
+ type frame struct {
+ id uuid.UUID
+ exits []uuid.UUID
+ next int
+ }
+ for _, root := range g.ordered {
+ if colour[root.ID] != white {
+ continue
+ }
+ stack := []frame{{id: root.ID, exits: g.exits(root.ID)}}
+ colour[root.ID] = grey
+ for len(stack) > 0 {
+ top := &stack[len(stack)-1]
+ if top.next == len(top.exits) {
+ colour[top.id] = black
+ stack = stack[:len(stack)-1]
+ continue
+ }
+ child := top.exits[top.next]
+ top.next++
+ switch colour[child] {
+ case grey:
+ // The loop is the stack from child's frame to the top.
+ var loop []uuid.UUID
+ for i := range stack {
+ if stack[i].id == child {
+ for _, f := range stack[i:] {
+ loop = append(loop, f.id)
+ }
+ break
+ }
+ }
+ return &CycleError{StepIDs: loop}
+ case white:
+ colour[child] = grey
+ stack = append(stack, frame{id: child, exits: g.exits(child)})
+ }
+ }
+ }
+ return nil
+}
+
+// PlanKind is what happens after a step has been sent.
+type PlanKind int
+
+const (
+ // PlanEnd: the path ends after this step; the enrollment completes.
+ PlanEnd PlanKind = iota
+ // PlanNext: Next is sent next, DelaySeconds after this step's send.
+ PlanNext
+ // PlanAwait: a condition must be decided first; see Branch.
+ PlanAwait
+)
+
+// Plan is the route out of one step.
+type Plan struct {
+ Kind PlanKind
+ Next Step
+ Branch Branch
+}
+
+// After is the route out of stepID. An exit to a step that is not in the graph
+// ends the path — the database already nulls such exits when a step is deleted,
+// and routing into nothing is never the right answer. An unknown stepID also
+// yields PlanEnd; callers that can do better (the send path falls back to the
+// linear rule for a cursor on a deleted step) check Step first.
+func (g Graph) After(stepID uuid.UUID) Plan {
+ if _, ok := g.byID[stepID]; !ok {
+ return Plan{Kind: PlanEnd}
+ }
+ b, ok := g.branches[stepID]
+ if !ok {
+ if n, ok := g.DefaultNext(stepID); ok {
+ return Plan{Kind: PlanNext, Next: n}
+ }
+ return Plan{Kind: PlanEnd}
+ }
+ if b.Condition != Always {
+ return Plan{Kind: PlanAwait, Branch: b}
+ }
+ if n, ok := g.byID[b.Yes]; ok {
+ return Plan{Kind: PlanNext, Next: n}
+ }
+ return Plan{Kind: PlanEnd}
+}
+
+// Verdict is the state of one condition.
+type Verdict struct {
+ // Decided is false while the window is open and nothing has settled it.
+ Decided bool
+ // Yes is the outcome once decided.
+ Yes bool
+ // At is when it was decided (the deciding event, or the deadline); while
+ // undecided it is the deadline, the latest moment the verdict can settle.
+ At time.Time
+}
+
+// Evaluate decides a condition from the earliest qualifying event.
+//
+// start is when the source step was sent and window how long the condition
+// watches; firstEvent is the earliest event of the condition's signal at or
+// before start+window (zero = none seen). The verdict is a pure function of
+// those, so re-evaluating an enrollment later can never change a decided answer:
+// events after the window are ignored, and once the window has closed no new
+// event can land inside it. That is what lets the send path re-derive the route
+// on every advance instead of storing it.
+//
+// - A positive condition (opened/clicked/replied) is decided YES the moment the
+// event happens, and NO when the window closes without it.
+// - A negated one (not_opened/not_replied) is decided NO the moment the event
+// happens, and YES when the window closes without it.
+//
+// A caller that passes an event after the deadline gets the no-event answer.
+func Evaluate(c Condition, start time.Time, window time.Duration, firstEvent, now time.Time) Verdict {
+ if c == Always {
+ return Verdict{Decided: true, Yes: true, At: start}
+ }
+ deadline := start.Add(window)
+ if !firstEvent.IsZero() && !firstEvent.After(deadline) {
+ at := firstEvent
+ if at.Before(start) {
+ at = start
+ }
+ return Verdict{Decided: true, Yes: !c.negated(), At: at}
+ }
+ if !now.Before(deadline) {
+ return Verdict{Decided: true, Yes: c.negated(), At: deadline}
+ }
+ return Verdict{At: deadline}
+}
diff --git a/internal/platform/seqgraph/seqgraph_test.go b/internal/platform/seqgraph/seqgraph_test.go
new file mode 100644
index 00000000..63c5bf00
--- /dev/null
+++ b/internal/platform/seqgraph/seqgraph_test.go
@@ -0,0 +1,354 @@
+package seqgraph
+
+import (
+ "errors"
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// ids returns n deterministic, distinct step ids so failures print stably.
+func ids(n int) []uuid.UUID {
+ out := make([]uuid.UUID, n)
+ for i := range out {
+ out[i] = uuid.NewSHA1(uuid.NameSpaceOID, []byte{byte(i + 1)})
+ }
+ return out
+}
+
+// linear builds steps 1..n with the given delays (all 0 when omitted).
+func linear(id []uuid.UUID) []Step {
+ steps := make([]Step, len(id))
+ for i := range id {
+ steps[i] = Step{ID: id[i], Order: int32(i + 1), DelaySeconds: int32(i * 60)}
+ }
+ return steps
+}
+
+func TestParseCondition(t *testing.T) {
+ for _, c := range []Condition{Always, Opened, Clicked, Replied, NotOpened, NotReplied} {
+ got, ok := ParseCondition(string(c))
+ if !ok || got != c {
+ t.Errorf("ParseCondition(%q) = %q, %v", c, got, ok)
+ }
+ }
+ for _, bad := range []string{"", "OPENED", "bounced", "not-opened"} {
+ if _, ok := ParseCondition(bad); ok {
+ t.Errorf("ParseCondition(%q) accepted an unknown condition", bad)
+ }
+ }
+}
+
+func TestValidateShape(t *testing.T) {
+ id := ids(3)
+ cases := []struct {
+ name string
+ b Branch
+ code string // "" = valid
+ }{
+ {"always to a step", Branch{StepID: id[0], Condition: Always, Yes: id[1]}, ""},
+ {"always to the end", Branch{StepID: id[0], Condition: Always}, ""},
+ {"opened both exits", Branch{StepID: id[0], Condition: Opened, WithinDays: 3, Yes: id[1], No: id[2]}, ""},
+ {"replied with label", Branch{StepID: id[0], Condition: Replied, WithinDays: 1, ReplyLabelKey: "positive"}, ""},
+ {"not_replied with label", Branch{StepID: id[0], Condition: NotReplied, WithinDays: 90, ReplyLabelKey: "out_of_office"}, ""},
+ {"unknown condition", Branch{StepID: id[0], Condition: "bounced", WithinDays: 3}, CodeInvalidCondition},
+ {"condition without window", Branch{StepID: id[0], Condition: Opened}, CodeInvalidWithinDays},
+ {"window above max", Branch{StepID: id[0], Condition: Opened, WithinDays: MaxWithinDays + 1}, CodeInvalidWithinDays},
+ {"negative window", Branch{StepID: id[0], Condition: Clicked, WithinDays: -1}, CodeInvalidWithinDays},
+ {"always with window", Branch{StepID: id[0], Condition: Always, WithinDays: 3}, CodeInvalidWithinDays},
+ {"always with a no exit", Branch{StepID: id[0], Condition: Always, No: id[1]}, CodeNoExitNotAllowed},
+ {"label on an open condition", Branch{StepID: id[0], Condition: Opened, WithinDays: 3, ReplyLabelKey: "positive"}, CodeLabelNotAllowed},
+ {"malformed label key", Branch{StepID: id[0], Condition: Replied, WithinDays: 3, ReplyLabelKey: "Positive!"}, CodeLabelNotAllowed},
+ {"yes to itself", Branch{StepID: id[0], Condition: Opened, WithinDays: 3, Yes: id[0]}, CodeCycle},
+ {"no to itself", Branch{StepID: id[0], Condition: Opened, WithinDays: 3, No: id[0]}, CodeCycle},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ err := tc.b.ValidateShape()
+ if tc.code == "" {
+ if err != nil {
+ t.Fatalf("want valid, got %v", err)
+ }
+ return
+ }
+ if got := CodeOf(err); got != tc.code {
+ t.Fatalf("code = %q (err %v), want %q", got, err, tc.code)
+ }
+ })
+ }
+}
+
+func TestValidateLinearIsAcyclic(t *testing.T) {
+ g := New(linear(ids(4)), nil)
+ if err := g.Validate(); err != nil {
+ t.Fatalf("a linear sequence must validate: %v", err)
+ }
+ if g.HasBranches() {
+ t.Fatal("no branches were given")
+ }
+}
+
+func TestValidateEmptyGraph(t *testing.T) {
+ if err := New(nil, nil).Validate(); err != nil {
+ t.Fatalf("an empty campaign must validate: %v", err)
+ }
+}
+
+func TestValidateRejectsExplicitCycle(t *testing.T) {
+ id := ids(3)
+ // 1 -always-> 2 -always-> 3 -always-> 1
+ g := New(linear(id), []Branch{
+ {StepID: id[0], Condition: Always, Yes: id[1]},
+ {StepID: id[1], Condition: Always, Yes: id[2]},
+ {StepID: id[2], Condition: Always, Yes: id[0]},
+ })
+ var cyc *CycleError
+ if err := g.Validate(); !errors.As(err, &cyc) {
+ t.Fatalf("want CycleError, got %v", err)
+ }
+ if CodeOf(cyc) != CodeCycle {
+ t.Fatalf("cycle code = %q", CodeOf(cyc))
+ }
+ for _, want := range id {
+ if !slices.Contains(cyc.StepIDs, want) {
+ t.Fatalf("cycle %v is missing step %v", cyc.StepIDs, want)
+ }
+ }
+}
+
+// The cycle a naive check misses: only ONE explicit edge, closed by the implicit
+// "next by step_order" fall-through of steps that have no branch.
+func TestValidateRejectsCycleThroughFallThrough(t *testing.T) {
+ id := ids(3)
+ g := New(linear(id), []Branch{
+ // Step 3 jumps back to step 1; 1 -> 2 -> 3 is implicit.
+ {StepID: id[2], Condition: Always, Yes: id[0]},
+ })
+ var cyc *CycleError
+ if err := g.Validate(); !errors.As(err, &cyc) {
+ t.Fatalf("want CycleError for a cycle closed by fall-through, got %v", err)
+ }
+ if len(cyc.StepIDs) != 3 {
+ t.Fatalf("cycle = %v, want all three steps", cyc.StepIDs)
+ }
+}
+
+// A conditional's NO exit is an edge like any other.
+func TestValidateRejectsCycleThroughNoExit(t *testing.T) {
+ id := ids(3)
+ g := New(linear(id), []Branch{
+ {StepID: id[1], Condition: NotOpened, WithinDays: 2, Yes: id[2], No: id[0]},
+ })
+ if err := g.Validate(); CodeOf(err) != CodeCycle {
+ t.Fatalf("want cycle via the no exit, got %v", err)
+ }
+}
+
+// A backward edge is NOT a cycle when the target cannot reach the source: here
+// step 3 jumps to step 2, and step 2 ends explicitly.
+func TestValidateAllowsBackwardEdgeWithoutCycle(t *testing.T) {
+ id := ids(3)
+ g := New(linear(id), []Branch{
+ {StepID: id[0], Condition: Opened, WithinDays: 3, Yes: id[2], No: id[1]},
+ {StepID: id[1], Condition: Always}, // 2 ends
+ {StepID: id[2], Condition: Always, Yes: id[1]}, // 3 -> 2 (backward, acyclic)
+ })
+ if err := g.Validate(); err != nil {
+ t.Fatalf("1 -> {3 -> 2, 2} is acyclic: %v", err)
+ }
+}
+
+func TestValidateRejectsTargetOutsideCampaign(t *testing.T) {
+ id := ids(3)
+ foreign := uuid.New()
+ g := New(linear(id[:2]), []Branch{
+ {StepID: id[0], Condition: Replied, WithinDays: 3, Yes: foreign},
+ })
+ var te *TargetError
+ if err := g.Validate(); !errors.As(err, &te) {
+ t.Fatalf("want TargetError, got %v", err)
+ }
+ if te.Target != foreign || te.StepID != id[0] || CodeOf(te) != CodeUnknownTarget {
+ t.Fatalf("TargetError = %+v", te)
+ }
+}
+
+func TestValidateRejectsBranchOnForeignStep(t *testing.T) {
+ id := ids(2)
+ g := New(linear(id[:1]), []Branch{{StepID: id[1], Condition: Always}})
+ if err := g.Validate(); CodeOf(err) != CodeUnknownStep {
+ t.Fatalf("want %q, got %v", CodeUnknownStep, err)
+ }
+}
+
+func TestValidateReportsShapeErrors(t *testing.T) {
+ id := ids(2)
+ g := New(linear(id), []Branch{{StepID: id[0], Condition: Opened}})
+ if err := g.Validate(); CodeOf(err) != CodeInvalidWithinDays {
+ t.Fatalf("Validate must include the per-branch shape check, got %v", err)
+ }
+}
+
+func TestDefaultNextToleratesGaps(t *testing.T) {
+ id := ids(3)
+ g := New([]Step{
+ {ID: id[2], Order: 7},
+ {ID: id[0], Order: 1},
+ {ID: id[1], Order: 3},
+ }, nil)
+ if n, ok := g.DefaultNext(id[0]); !ok || n.ID != id[1] {
+ t.Fatalf("next after order 1 = %+v %v, want order 3", n, ok)
+ }
+ if n, ok := g.DefaultNext(id[1]); !ok || n.ID != id[2] {
+ t.Fatalf("next after order 3 = %+v %v, want order 7", n, ok)
+ }
+ if _, ok := g.DefaultNext(id[2]); ok {
+ t.Fatal("the last step has no default next")
+ }
+ if e, ok := g.Entry(); !ok || e.ID != id[0] {
+ t.Fatalf("entry = %+v %v", e, ok)
+ }
+ if s, ok := g.StepByOrder(3); !ok || s.ID != id[1] {
+ t.Fatalf("StepByOrder(3) = %+v %v", s, ok)
+ }
+ if _, ok := g.StepByOrder(2); ok {
+ t.Fatal("order 2 is a gap")
+ }
+}
+
+func TestAfter(t *testing.T) {
+ id := ids(4)
+ g := New(linear(id), []Branch{
+ {StepID: id[0], Condition: Opened, WithinDays: 2, Yes: id[2], No: id[1]},
+ {StepID: id[1], Condition: Always, Yes: id[3]},
+ {StepID: id[2], Condition: Always},
+ })
+
+ if p := g.After(id[0]); p.Kind != PlanAwait || p.Branch.Condition != Opened {
+ t.Fatalf("step 1 has a condition: %+v", p)
+ }
+ if p := g.After(id[1]); p.Kind != PlanNext || p.Next.ID != id[3] {
+ t.Fatalf("step 2 always -> 4: %+v", p)
+ }
+ if p := g.After(id[2]); p.Kind != PlanEnd {
+ t.Fatalf("step 3 always -> end: %+v", p)
+ }
+ if p := g.After(id[3]); p.Kind != PlanEnd {
+ t.Fatalf("step 4 is last with no branch: %+v", p)
+ }
+
+ // No branch at all: fall through by order — the linear rule.
+ lin := New(linear(id), nil)
+ if p := lin.After(id[1]); p.Kind != PlanNext || p.Next.ID != id[2] {
+ t.Fatalf("linear step 2 -> 3: %+v", p)
+ }
+ // An unknown step (deleted while an enrollment sat on it) has no plan to
+ // follow; the caller decides.
+ if p := lin.After(uuid.New()); p.Kind != PlanEnd {
+ t.Fatalf("unknown step: %+v", p)
+ }
+}
+
+// An exit whose target no longer exists in the graph ends the path rather than
+// routing into nothing. The database nulls such exits (ON DELETE SET NULL); this
+// is the in-memory half of the same rule.
+func TestAfterMissingTargetEnds(t *testing.T) {
+ id := ids(2)
+ g := New(linear(id[:1]), []Branch{{StepID: id[0], Condition: Always, Yes: id[1]}})
+ if p := g.After(id[0]); p.Kind != PlanEnd {
+ t.Fatalf("always -> missing step must end: %+v", p)
+ }
+}
+
+func TestEvaluate(t *testing.T) {
+ start := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
+ window := 3 * 24 * time.Hour
+ deadline := start.Add(window)
+ early := start.Add(5 * time.Hour)
+ late := deadline.Add(time.Hour)
+ var none time.Time
+
+ cases := []struct {
+ name string
+ cond Condition
+ first time.Time
+ now time.Time
+ decided bool
+ yes bool
+ at time.Time
+ }{
+ // Positive conditions: the event decides YES the moment it happens.
+ {"opened early", Opened, early, early.Add(time.Minute), true, true, early},
+ {"clicked early", Clicked, early, deadline.Add(-time.Minute), true, true, early},
+ {"replied early", Replied, early, early, true, true, early},
+ // ...and the window closing without it decides NO, at the deadline.
+ {"opened window closed", Opened, none, deadline, true, false, deadline},
+ {"replied window long closed", Replied, none, late, true, false, deadline},
+ // Still open, nothing seen: wait.
+ {"opened pending", Opened, none, early, false, false, deadline},
+ // Negated conditions: the event decides NO immediately...
+ {"not_opened but opened", NotOpened, early, early, true, false, early},
+ {"not_replied but replied", NotReplied, early, early.Add(time.Hour), true, false, early},
+ // ...and silence until the deadline decides YES.
+ {"not_opened window closed", NotOpened, none, deadline, true, true, deadline},
+ {"not_replied pending", NotReplied, none, early, false, false, deadline},
+ // An event after the window does not count, whatever the clock says now.
+ {"opened after window", Opened, late, late, true, false, deadline},
+ {"not_opened open after window", NotOpened, late, late.Add(time.Hour), true, true, deadline},
+ // Exactly on the deadline is inside the window.
+ {"opened on the deadline", Opened, deadline, deadline, true, true, deadline},
+ // An event stamped before the window start (clock skew between the send
+ // and the cursor stamp) decides at the start, never before it.
+ {"opened before start", Opened, start.Add(-time.Second), start, true, true, start},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ v := Evaluate(tc.cond, start, window, tc.first, tc.now)
+ if v.Decided != tc.decided || v.Yes != tc.yes || !v.At.Equal(tc.at) {
+ t.Fatalf("Evaluate = %+v, want decided=%v yes=%v at=%v", v, tc.decided, tc.yes, tc.at)
+ }
+ })
+ }
+}
+
+// 'always' is not evaluated against evidence; it is routed by After. Evaluate
+// must still be total, and decides YES immediately so a caller that asks cannot
+// wedge an enrollment.
+func TestEvaluateAlwaysIsImmediateYes(t *testing.T) {
+ start := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
+ v := Evaluate(Always, start, 0, time.Time{}, start)
+ if !v.Decided || !v.Yes || !v.At.Equal(start) {
+ t.Fatalf("Evaluate(always) = %+v", v)
+ }
+}
+
+func TestBranchTarget(t *testing.T) {
+ id := ids(3)
+ b := Branch{StepID: id[0], Condition: Opened, WithinDays: 1, Yes: id[1], No: id[2]}
+ if got := b.Target(Verdict{Decided: true, Yes: true}); got != id[1] {
+ t.Fatalf("yes target = %v", got)
+ }
+ if got := b.Target(Verdict{Decided: true, Yes: false}); got != id[2] {
+ t.Fatalf("no target = %v", got)
+ }
+ if got := (Branch{Condition: Opened, WithinDays: 1}).Target(Verdict{Decided: true, Yes: true}); got != uuid.Nil {
+ t.Fatalf("an unset exit is the end, got %v", got)
+ }
+ if b.Window() != 24*time.Hour {
+ t.Fatalf("window = %v", b.Window())
+ }
+}
+
+func TestSignal(t *testing.T) {
+ cases := map[Condition]Signal{
+ Always: SignalNone, Opened: SignalOpen, NotOpened: SignalOpen,
+ Clicked: SignalClick, Replied: SignalReply, NotReplied: SignalReply,
+ }
+ for c, want := range cases {
+ if got := c.Signal(); got != want {
+ t.Errorf("%q.Signal() = %v, want %v", c, got, want)
+ }
+ }
+}
diff --git a/internal/worker/inbox/branching_integration_test.go b/internal/worker/inbox/branching_integration_test.go
new file mode 100644
index 00000000..5a91744e
--- /dev/null
+++ b/internal/worker/inbox/branching_integration_test.go
@@ -0,0 +1,224 @@
+//go:build integration
+
+package inbox
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/inroad/inroad/internal/coreapi"
+ "github.com/inroad/inroad/internal/platform/db/gen"
+ "github.com/inroad/inroad/internal/platform/mail"
+ "github.com/inroad/inroad/internal/platform/replyclassify"
+)
+
+// Reply-routed branches, driven through the REAL inbox poll and dispatch: the
+// classifier picks the key, the workspace's label flags decide what the reply
+// does, and only then does the send path see whatever the dispatch left behind.
+// A test that wrote inbox rows and called RecordReplyClass by hand would pass
+// for a reply the dispatch would actually have used to STOP the sequence.
+
+// humanReplyBody is a plain human answer to step 1; oooSubject marks an
+// out-of-office one (the classifier's subject rule).
+const (
+ humanReplyBody = "\n\nSounds good, tell me more.\n"
+ oooSubject = "Out of Office: back Monday"
+)
+
+type branchInbox struct {
+ itFixture
+ pool *pgxpool.Pool
+ msgID string
+ uidNext uint32
+ classify *replyclassify.Classifier
+}
+
+// seedReplyBranch is the inbox fixture (step 1 sent as msgID) with a
+// "replied within 3 days → step 2, otherwise end" branch on step 1, step 2
+// due immediately, a send window open around the clock (so nothing depends on
+// the hour the test runs), and the enrollment already parked on the condition.
+func seedReplyBranch(t *testing.T) (branchInbox, func()) {
+ t.Helper()
+ ctx := context.Background()
+ pool, q, closeFn := connect(t)
+ msgID := ""
+ fx := seedActiveEnrollment(t, ctx, pool, q, newSealer(t), msgID)
+
+ steps, err := q.ListStepsByCampaign(ctx, gen.ListStepsByCampaignParams{CampaignID: fx.campaignID, WorkspaceID: fx.ws})
+ if err != nil || len(steps) != 2 {
+ t.Fatalf("steps: %v (%d)", err, len(steps))
+ }
+ for _, stmt := range []string{
+ `UPDATE sequence_steps SET delay_seconds = 0 WHERE campaign_id = $1`,
+ `INSERT INTO campaign_send_windows (workspace_id, campaign_id, weekday, start_minute, end_minute)
+ SELECT c.workspace_id, c.id, d, 0, 1440 FROM campaigns c, generate_series(0, 6) AS d WHERE c.id = $1`,
+ } {
+ if _, err := pool.Exec(ctx, stmt, fx.campaignID); err != nil {
+ t.Fatalf("setup: %v", err)
+ }
+ }
+ three := int32(3)
+ if _, err := q.UpsertBranch(ctx, gen.UpsertBranchParams{
+ StepID: steps[0].ID, WorkspaceID: fx.ws, CampaignID: fx.campaignID, Condition: "replied",
+ WithinDays: &three, YesStepID: pgtype.UUID{Bytes: steps[1].ID, Valid: true},
+ }); err != nil {
+ t.Fatalf("branch: %v", err)
+ }
+ if err := fx.core.SetInboxCursor(ctx, fx.mailboxID.String(), fx.ws.String(), 10, 5); err != nil {
+ t.Fatalf("seed cursor: %v", err)
+ }
+ b := branchInbox{itFixture: fx, pool: pool, msgID: msgID, uidNext: 11, classify: replyclassify.New(nil)}
+
+ // Nothing has arrived: the condition is open, so the enrollment parks.
+ if job := b.job(t); !job.ConditionPending {
+ t.Fatalf("before any reply the enrollment must wait on the condition, got %+v", job)
+ }
+ return b, closeFn
+}
+
+func (b *branchInbox) raw(subject, body string) string {
+ return "From: " + b.email + "\nTo: from@acme.test\nSubject: " + subject +
+ "\nMessage-ID: \nIn-Reply-To: " + b.msgID +
+ "\nReferences: " + b.msgID + "\n" + body
+}
+
+// classOf is what the production classifier makes of a raw message.
+func (b *branchInbox) classOf(t *testing.T, raw string) string {
+ t.Helper()
+ // The same Input poll.go builds from a fetched message.
+ msg := inboundMsg(t, 0, raw)
+ return b.classify.Classify(context.Background(), replyclassify.Input{
+ Headers: map[string][]string(msg.Header), Subject: msg.Header.Get("Subject"), BodyText: string(msg.Body),
+ }).Class
+}
+
+// poll delivers one message through PollHandler, exactly as the scheduler does.
+func (b *branchInbox) poll(t *testing.T, raw string) {
+ t.Helper()
+ uid := b.uidNext
+ b.uidNext++
+ reader := &fakeReader{uidValidity: 5, uidNext: b.uidNext, msgs: []mail.InboundMessage{inboundMsg(t, uid, raw)}}
+ if err := PollHandler(b.core, reader, nil, nil, b.classify, nil, noopEngageEnqueuer{})(
+ context.Background(), pollTaskFor(t, b.mailboxID.String(), b.ws.String())); err != nil {
+ t.Fatalf("poll: %v", err)
+ }
+}
+
+func (b *branchInbox) job(t *testing.T) coreapi.StepSendJob {
+ t.Helper()
+ job, err := b.core.GetStepSendJob(context.Background(), b.enrollmentID.String(), b.ws.String())
+ if err != nil {
+ t.Fatalf("GetStepSendJob: %v", err)
+ }
+ return job
+}
+
+func (b *branchInbox) enrollment(t *testing.T) gen.SequenceEnrollment {
+ t.Helper()
+ return getEnrollment(t, context.Background(), b.q, b.ws, b.enrollmentID)
+}
+
+// With the workspace's label for a human reply set NOT to stop the sequence,
+// the reply is routed: an out-of-office first is neither a reply nor a nudge
+// (it is the kind of reply that defers), then the human reply pulls the parked
+// enrollment forward and the send path routes it down the YES exit.
+func TestReplyBranchRoutesOnANonStoppingLabel(t *testing.T) {
+ b, done := seedReplyBranch(t)
+ defer done()
+ ctx := context.Background()
+
+ human := b.raw("Re: Hi", humanReplyBody)
+ key := b.classOf(t, human)
+ if replyclassify.IsAutomated(key) {
+ t.Fatalf("precondition: a plain reply classified as automated %q", key)
+ }
+ if _, err := b.pool.Exec(ctx,
+ `UPDATE reply_labels SET stops_enrollment = false WHERE workspace_id = $1 AND key = $2`, b.ws, key); err != nil {
+ t.Fatalf("make %q non-stopping: %v", key, err)
+ }
+
+ ooo := b.raw(oooSubject, "\n\nI am away until Monday.\n")
+ if got := b.classOf(t, ooo); got != replyclassify.ClassOutOfOffice {
+ t.Fatalf("precondition: out-of-office message classified %q", got)
+ }
+ parked := b.enrollment(t).NextDueAt.Time
+ b.poll(t, ooo)
+ if e := b.enrollment(t); e.Status != "active" || !e.NextDueAt.Time.Equal(parked) {
+ t.Fatalf("an out-of-office moved the enrollment: status %s due %v -> %v", e.Status, parked, e.NextDueAt.Time)
+ }
+ if job := b.job(t); !job.ConditionPending {
+ t.Fatalf("an out-of-office is not a reply, the condition must stay open: %+v", job)
+ }
+
+ b.poll(t, human)
+ e := b.enrollment(t)
+ if e.Status != "active" {
+ t.Fatalf("a non-stopping reply stopped the enrollment: %s", e.Status)
+ }
+ if e.NextDueAt.Time.After(time.Now().Add(time.Minute)) {
+ t.Fatalf("the reply must nudge the parked enrollment to now, due %v", e.NextDueAt.Time)
+ }
+ job := b.job(t)
+ if job.ConditionPending || job.Skip || job.StepOrder != 2 {
+ t.Fatalf("the reply must route to step 2 (yes): pending=%v skip=%v order=%d", job.ConditionPending, job.Skip, job.StepOrder)
+ }
+}
+
+// The product decision, pinned: with the workspace's DEFAULT labels a human
+// reply STOPS the sequence exactly as it did before branching existed — the
+// branch does not get a say, and the send path has nothing left to route.
+func TestReplyBranchDefaultLabelStopsInsteadOfRouting(t *testing.T) {
+ b, done := seedReplyBranch(t)
+ defer done()
+
+ human := b.raw("Re: Hi", humanReplyBody)
+ var stops bool
+ if err := b.pool.QueryRow(context.Background(),
+ `SELECT stops_enrollment FROM reply_labels WHERE workspace_id = $1 AND key = $2`,
+ b.ws, b.classOf(t, human)).Scan(&stops); err != nil || !stops {
+ t.Fatalf("precondition: the seeded label for a human reply must stop the sequence (stops=%v err=%v)", stops, err)
+ }
+
+ b.poll(t, human)
+ e := b.enrollment(t)
+ if e.Status != "stopped" || e.StopReason == nil || *e.StopReason != "replied" {
+ t.Fatalf("a default-label reply must stop the enrollment 'replied', got %s %v", e.Status, e.StopReason)
+ }
+ if job := b.job(t); !job.Skip || job.ConditionPending || job.StepOrder != 0 {
+ t.Fatalf("a stopped enrollment routes nowhere: %+v", job)
+ }
+}
+
+// A due time the enrollment carries for a reason OTHER than a condition wait —
+// here an out-of-office deferral five days out (DeferEnrollment never stamps
+// awaiting_condition_step) — is not the nudge's to pull forward, even for a
+// non-stopping human reply the branch is watching for. The stated absence
+// wins; the reply is still evidence, read when the enrollment next wakes.
+func TestReplyNudgeLeavesAnOutOfOfficeDeferralAlone(t *testing.T) {
+ b, done := seedReplyBranch(t)
+ defer done()
+ ctx := context.Background()
+
+ human := b.raw("Re: Hi", humanReplyBody)
+ if _, err := b.pool.Exec(ctx,
+ `UPDATE reply_labels SET stops_enrollment = false WHERE workspace_id = $1 AND key = $2`, b.ws, b.classOf(t, human)); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := b.pool.Exec(ctx, `UPDATE sequence_enrollments SET awaiting_condition_step = NULL WHERE id = $1`, b.enrollmentID); err != nil {
+ t.Fatal(err)
+ }
+ until := time.Now().Add(5 * 24 * time.Hour).UTC().Truncate(time.Second)
+ if err := b.core.DeferEnrollment(ctx, b.enrollmentID.String(), b.ws.String(), until); err != nil {
+ t.Fatal(err)
+ }
+
+ b.poll(t, human)
+ if e := b.enrollment(t); e.Status != "active" || !e.NextDueAt.Time.Equal(until) {
+ t.Fatalf("the out-of-office deferral was overwritten: status %s due %v, want %v", e.Status, e.NextDueAt.Time, until)
+ }
+}
diff --git a/internal/worker/sequence/advance.go b/internal/worker/sequence/advance.go
index 7cb75af2..ca8e120e 100644
--- a/internal/worker/sequence/advance.go
+++ b/internal/worker/sequence/advance.go
@@ -167,6 +167,20 @@ func AdvanceHandler(core coreapi.Client, sender Sender, enq Enqueuer, publicURL
// The gmail access token is a decrypted secret too; wipe it after use.
defer zeroize(job.AccessToken)
+ // A branch condition on the enrollment's current step is still open, or
+ // the step it routed to is not due yet. The control plane has already
+ // stamped next_due_at; all that is left is to look again then. Checked
+ // BEFORE Skip, which rides along on this job only so that a worker built
+ // before ConditionPending existed does nothing harmful with it.
+ //
+ // Deliberately NO inroad_sends_total increment. A condition wait is not a
+ // send outcome at all — nothing was due to go out — and it recurs hourly
+ // for every waiting enrollment, so counting it as result="deferred" would
+ // swamp the capacity and limit defers that bucket exists to show.
+ if job.ConditionPending {
+ return enq.EnqueueAdvanceAt(ctx, p.EnrollmentID, p.WorkspaceID, job.RecheckAt)
+ }
+
// Enrollment no longer active (stopped/completed) or no next step.
// result=skipped: nothing was ever actionable — same bucket as a
// racing worker's already-claimed row below (ClaimSkip), never
diff --git a/internal/worker/sequence/branching_integration_test.go b/internal/worker/sequence/branching_integration_test.go
new file mode 100644
index 00000000..c9c0a59a
--- /dev/null
+++ b/internal/worker/sequence/branching_integration_test.go
@@ -0,0 +1,468 @@
+//go:build integration
+
+package sequence
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/inroad/inroad/internal/platform/db/gen"
+)
+
+// branchFixture is a running three-step campaign (subjects "S1".."S3", all
+// zero-delay) with a send window open around the clock — so no test outcome
+// depends on the hour it happens to run — and one enrolled contact.
+type branchFixture struct {
+ itFixture
+ pool *pgxpool.Pool
+ eid string
+ steps [3]uuid.UUID
+ snd *itSender
+ enq *itEnq
+}
+
+func seedBranchCampaign(t *testing.T) (branchFixture, func()) {
+ t.Helper()
+ ctx := context.Background()
+ pool, q, closeFn := connect(t)
+ fx := seedCampaign(t, ctx, pool, q, newSealer(t), [][3]string{
+ {"S1", "one", "0"}, {"S2", "two", "0"}, {"S3", "three", "0"},
+ })
+ if _, err := pool.Exec(ctx, `
+ INSERT INTO campaign_send_windows (workspace_id, campaign_id, weekday, start_minute, end_minute)
+ SELECT $1, $2, d, 0, 1440 FROM generate_series(0, 6) AS d`, fx.ws, fx.campaignID); err != nil {
+ t.Fatalf("send windows: %v", err)
+ }
+ steps, err := q.ListStepsByCampaign(ctx, gen.ListStepsByCampaignParams{CampaignID: fx.campaignID, WorkspaceID: fx.ws})
+ if err != nil || len(steps) != 3 {
+ t.Fatalf("steps: %v (%d)", err, len(steps))
+ }
+ ids, err := q.EnrollListMembers(ctx, gen.EnrollListMembersParams{ID: fx.campaignID, WorkspaceID: fx.ws})
+ if err != nil || len(ids) != 1 {
+ t.Fatalf("enroll: %v", err)
+ }
+ return branchFixture{
+ itFixture: fx, pool: pool, eid: ids[0].ID.String(),
+ steps: [3]uuid.UUID{steps[0].ID, steps[1].ID, steps[2].ID},
+ snd: &itSender{}, enq: newITEnq(),
+ }, closeFn
+}
+
+// branch writes a router directly through the query (the save-time validation
+// has its own tests in internal/app/sequencestep).
+func (f branchFixture) branch(t *testing.T, step uuid.UUID, cond string, within int32, yes, no *uuid.UUID) {
+ t.Helper()
+ var w *int32
+ if cond != "always" {
+ w = &within
+ }
+ if _, err := f.q.UpsertBranch(context.Background(), gen.UpsertBranchParams{
+ StepID: step, WorkspaceID: f.ws, CampaignID: f.campaignID, Condition: cond, WithinDays: w,
+ YesStepID: optUUID(yes), NoStepID: optUUID(no),
+ }); err != nil {
+ t.Fatalf("branch: %v", err)
+ }
+}
+
+func optUUID(id *uuid.UUID) pgtype.UUID {
+ if id == nil {
+ return pgtype.UUID{}
+ }
+ return pgtype.UUID{Bytes: *id, Valid: true}
+}
+
+func (f branchFixture) advance(t *testing.T) {
+ t.Helper()
+ advance(t, f.core, f.snd, f.enq, f.eid, f.ws.String())
+}
+
+// due models the advance task's wait: next_due_at back to now.
+func (f branchFixture) due(t *testing.T) {
+ t.Helper()
+ arriveAtDueTime(t, context.Background(), f.pool, f.eid)
+}
+
+// ageLastSend moves the cursor step's send (and its window) d into the past.
+func (f branchFixture) ageLastSend(t *testing.T, d time.Duration) {
+ t.Helper()
+ if _, err := f.pool.Exec(context.Background(),
+ `UPDATE sequence_enrollments SET last_sent_at = last_sent_at - make_interval(secs => $2), next_due_at = now()
+ WHERE id = $1`, f.eid, d.Seconds()); err != nil {
+ t.Fatalf("age last send: %v", err)
+ }
+}
+
+// sendID is the deterministic sends row id of one step for this contact.
+func (f branchFixture) sendID(t *testing.T, order int) uuid.UUID {
+ t.Helper()
+ var id uuid.UUID
+ if err := f.pool.QueryRow(context.Background(),
+ `SELECT id FROM sends WHERE campaign_id = $1 AND contact_id = $2 AND step_order = $3`,
+ f.campaignID, f.contactID, order).Scan(&id); err != nil {
+ t.Fatalf("send row for step %d: %v", order, err)
+ }
+ return id
+}
+
+func (f branchFixture) track(t *testing.T, order int, kind string, machine bool) {
+ t.Helper()
+ reason := ""
+ if machine {
+ reason = "proxy_user_agent"
+ }
+ if _, err := f.pool.Exec(context.Background(), `
+ INSERT INTO tracking_events (workspace_id, campaign_id, send_id, kind, is_machine, machine_reason)
+ VALUES ($1, $2, $3, $4, $5, $6)`, f.ws, f.campaignID, f.sendID(t, order), kind, machine, reason); err != nil {
+ t.Fatalf("tracking event: %v", err)
+ }
+}
+
+func (f branchFixture) enrollment(t *testing.T) gen.SequenceEnrollment {
+ t.Helper()
+ e, err := f.q.GetEnrollment(context.Background(), gen.GetEnrollmentParams{ID: uuid.MustParse(f.eid), WorkspaceID: f.ws})
+ if err != nil {
+ t.Fatalf("enrollment: %v", err)
+ }
+ return e
+}
+
+func (f branchFixture) subjects() []string {
+ out := make([]string, len(f.snd.sent))
+ for i, m := range f.snd.sent {
+ out[i] = m.Subject
+ }
+ return out
+}
+
+// requireSent asserts the exact sequence of steps the contact has received.
+func (f branchFixture) requireSent(t *testing.T, want ...string) {
+ t.Helper()
+ got := f.subjects()
+ if len(got) != len(want) {
+ t.Fatalf("sent %v, want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("sent %v, want %v", got, want)
+ }
+ }
+}
+
+// A human open inside the window routes YES — step 1 → step 3, never step 2 —
+// and the enrollment completes on step 3.
+func TestBranchOpenedRoutesYes(t *testing.T) {
+ f, done := seedBranchCampaign(t)
+ defer done()
+ f.branch(t, f.steps[0], "opened", 2, &f.steps[2], &f.steps[1])
+
+ f.advance(t)
+ f.requireSent(t, "S1")
+ f.track(t, 1, "open", false)
+
+ f.due(t)
+ f.advance(t)
+ f.requireSent(t, "S1", "S3")
+ if e := f.enrollment(t); e.Status != "completed" || e.CurrentStep != 3 {
+ t.Fatalf("enrollment = %s at %d, want completed at 3", e.Status, e.CurrentStep)
+ }
+}
+
+// A MACHINE open (a proxy prefetch) is not an open: the branch keeps waiting,
+// and when the window closes it routes NO (docs/security.md invariant 84).
+func TestBranchMachineOpenDoesNotCount(t *testing.T) {
+ f, done := seedBranchCampaign(t)
+ defer done()
+ f.branch(t, f.steps[0], "opened", 1, &f.steps[2], &f.steps[1])
+
+ f.advance(t)
+ f.track(t, 1, "open", true)
+
+ f.due(t)
+ f.advance(t)
+ f.requireSent(t, "S1")
+ e := f.enrollment(t)
+ if e.Status != "active" || !e.NextDueAt.Valid || !e.NextDueAt.Time.After(time.Now()) {
+ t.Fatalf("a pending condition must park the enrollment in the future, got %s due=%v", e.Status, e.NextDueAt)
+ }
+ if e.AwaitingConditionStep == nil || *e.AwaitingConditionStep != 1 {
+ t.Fatalf("awaiting_condition_step = %v, want 1", e.AwaitingConditionStep)
+ }
+ // Postgres keeps microseconds; the enqueued instant carries nanoseconds.
+ if at, ok := f.enq.at[f.eid]; !ok || at.Sub(e.NextDueAt.Time).Abs() > time.Millisecond {
+ t.Fatalf("recheck enqueued at %v, stamped %v — they must agree", at, e.NextDueAt.Time)
+ }
+
+ f.ageLastSend(t, 25*time.Hour)
+ f.advance(t)
+ f.requireSent(t, "S1", "S2")
+}
+
+// A contact suppressed (unsubscribed, bounced) while waiting on a branch is
+// never sent the routed step: routing picks the target, and the send path's
+// suppression gate then stops the enrollment exactly as on a linear sequence.
+func TestBranchRoutedSendHonoursSuppression(t *testing.T) {
+ f, done := seedBranchCampaign(t)
+ defer done()
+ f.branch(t, f.steps[0], "opened", 2, &f.steps[2], &f.steps[1])
+
+ f.advance(t)
+ f.track(t, 1, "open", false) // decides YES -> step 3
+ if err := f.q.AddSuppression(context.Background(), gen.AddSuppressionParams{
+ WorkspaceID: f.ws, Email: f.email, Reason: "unsubscribe",
+ }); err != nil {
+ t.Fatalf("suppress: %v", err)
+ }
+
+ f.due(t)
+ f.advance(t)
+ f.requireSent(t, "S1")
+ e := f.enrollment(t)
+ if e.Status != "stopped" || e.StopReason == nil || *e.StopReason != "suppressed" {
+ t.Fatalf("routed send to a suppressed contact: status %s reason %v", e.Status, e.StopReason)
+ }
+}
+
+// A campaign that is not running holds its enrollments WITHOUT routing them: a
+// path that would end is not finished, a condition is not parked, nothing is
+// sent — the pause gate runs before routing on the graph path, just as it runs
+// before any send on the linear one. Relaunching resumes routing.
+func TestBranchPausedCampaignNeitherFinishesNorParks(t *testing.T) {
+ f, done := seedBranchCampaign(t)
+ defer done()
+ ctx := context.Background()
+ f.branch(t, f.steps[0], "not_opened", 1, nil, &f.steps[1]) // silence -> end
+
+ f.advance(t)
+ f.ageLastSend(t, 25*time.Hour) // the window has closed: this WOULD end the path
+ if _, err := f.pool.Exec(ctx, `UPDATE campaigns SET status = 'paused' WHERE id = $1`, f.campaignID); err != nil {
+ t.Fatal(err)
+ }
+ job, err := f.core.GetStepSendJob(ctx, f.eid, f.ws.String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !job.CampaignPaused || job.ConditionPending || job.Skip {
+ t.Fatalf("paused campaign job = paused %v pending %v skip %v, want paused only", job.CampaignPaused, job.ConditionPending, job.Skip)
+ }
+ if e := f.enrollment(t); e.Status != "active" || e.AwaitingConditionStep != nil {
+ t.Fatalf("a paused campaign moved the enrollment: status %s awaiting %v", e.Status, e.AwaitingConditionStep)
+ }
+
+ if _, err := f.pool.Exec(ctx, `UPDATE campaigns SET status = 'running' WHERE id = $1`, f.campaignID); err != nil {
+ t.Fatal(err)
+ }
+ f.advance(t)
+ f.requireSent(t, "S1")
+ if e := f.enrollment(t); e.Status != "completed" {
+ t.Fatalf("after relaunch the ended path completes, got %s", e.Status)
+ }
+}
+
+// The loop backstop's reference point is the CURRENT step's own send row, not
+// enrollment.last_sent_at. A recover-forward re-stamps last_sent_at, so an old
+// visit can look "later" than it; here last_sent_at is pushed into the past
+// (as if long ago) and the loop must still be caught — and, conversely, the
+// legitimate first visit to a step is not mistaken for a revisit.
+func TestBranchLoopBackstopUsesTheCurrentStepsSendRow(t *testing.T) {
+ f, done := seedBranchCampaign(t)
+ defer done()
+ f.branch(t, f.steps[0], "always", 0, &f.steps[1], nil)
+ f.branch(t, f.steps[1], "always", 0, &f.steps[0], nil) // 1 -> 2 -> 1
+
+ f.advance(t)
+ f.due(t)
+ f.advance(t)
+ f.requireSent(t, "S1", "S2")
+
+ // Make last_sent_at older than step 1's send row: comparing against it
+ // would call step 1 "not visited before" and recover-forward onto it.
+ f.ageLastSend(t, 48*time.Hour)
+ f.advance(t)
+ f.requireSent(t, "S1", "S2")
+ if e := f.enrollment(t); e.Status != "completed" || e.CurrentStep != 2 {
+ t.Fatalf("loop = %s at %d, want completed at 2", e.Status, e.CurrentStep)
+ }
+}
+
+// A route to an unset exit ENDS the path: the enrollment completes without a
+// send, and current_step/last_sent_at still describe the last real message.
+func TestBranchRoutedEndCompletesWithoutSend(t *testing.T) {
+ f, done := seedBranchCampaign(t)
+ defer done()
+ // not_opened: silence routes YES (unset = end); an open would route NO.
+ f.branch(t, f.steps[0], "not_opened", 1, nil, &f.steps[1])
+
+ f.advance(t)
+ before := f.enrollment(t)
+ f.ageLastSend(t, 25*time.Hour)
+ f.advance(t)
+
+ f.requireSent(t, "S1")
+ e := f.enrollment(t)
+ if e.Status != "completed" || e.CurrentStep != 1 || !e.CompletedAt.Valid || e.NextDueAt.Valid {
+ t.Fatalf("routed end = status %s step %d completed %v due %v", e.Status, e.CurrentStep, e.CompletedAt, e.NextDueAt)
+ }
+ // Aged by the test, not re-stamped by the finish.
+ if !e.LastSentAt.Time.Before(before.LastSentAt.Time) {
+ t.Fatalf("a routed end must not stamp last_sent_at (%v -> %v)", before.LastSentAt.Time, e.LastSentAt.Time)
+ }
+}
+
+// Mid-flight rule: a branch removed while the contact waits on it returns the
+// step to linear fall-through, and the successor still waits out ITS OWN delay
+// from the last send — even though the campaign now has no branches at all, so
+// only awaiting_condition_step routes it through the graph.
+func TestBranchRemovedMidWaitHonoursSuccessorDelay(t *testing.T) {
+ f, done := seedBranchCampaign(t)
+ defer done()
+ ctx := context.Background()
+ f.branch(t, f.steps[0], "opened", 3, &f.steps[2], &f.steps[1])
+
+ f.advance(t)
+ f.due(t)
+ f.advance(t) // parked on the condition
+ f.requireSent(t, "S1")
+
+ if err := f.q.DeleteBranch(ctx, gen.DeleteBranchParams{StepID: f.steps[0], CampaignID: f.campaignID, WorkspaceID: f.ws}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.pool.Exec(ctx, `UPDATE sequence_steps SET delay_seconds = 7200 WHERE id = $1`, f.steps[1]); err != nil {
+ t.Fatal(err)
+ }
+
+ f.due(t) // the recheck the deleted condition had scheduled fires
+ f.advance(t)
+ f.requireSent(t, "S1")
+
+ f.ageLastSend(t, 3*time.Hour)
+ f.advance(t)
+ f.requireSent(t, "S1", "S2")
+}
+
+// Mid-flight rule: a branch ADDED to a step after the contact received it
+// governs the next advance, with its window measured from that step's send.
+func TestBranchAddedAfterSendAppliesToNextAdvance(t *testing.T) {
+ f, done := seedBranchCampaign(t)
+ defer done()
+ f.advance(t) // S1 sent while the campaign was linear
+ f.branch(t, f.steps[0], "clicked", 1, &f.steps[2], &f.steps[1])
+
+ f.due(t)
+ f.advance(t)
+ f.requireSent(t, "S1") // now waiting on the click
+
+ f.track(t, 1, "click", false)
+ f.due(t)
+ f.advance(t)
+ f.requireSent(t, "S1", "S3")
+}
+
+// The runtime loop backstop: a cycle written past the save-time validation (a
+// direct write, here) ends the path instead of recovering-forward forever.
+func TestBranchLoopBackstopEndsPath(t *testing.T) {
+ f, done := seedBranchCampaign(t)
+ defer done()
+ f.branch(t, f.steps[0], "always", 0, &f.steps[1], nil)
+ f.branch(t, f.steps[1], "always", 0, &f.steps[0], nil) // 1 -> 2 -> 1
+
+ f.advance(t)
+ f.due(t)
+ f.advance(t)
+ f.requireSent(t, "S1", "S2")
+
+ f.due(t)
+ f.advance(t)
+ f.requireSent(t, "S1", "S2")
+ if e := f.enrollment(t); e.Status != "completed" || e.CurrentStep != 2 {
+ t.Fatalf("loop = %s at %d, want completed at 2", e.Status, e.CurrentStep)
+ }
+}
+
+// Branch routing threads onto the LATEST message the contact received, not the
+// highest-numbered step. On the path 1 -> 3 -> 2 -> 4 the third send (step 2)
+// is the latest when step 4 goes out, while step 3 is the highest-numbered one
+// sent — ordering by step_order would thread step 4 onto the wrong message.
+func TestBranchThreadsOntoLatestSend(t *testing.T) {
+ f, done := seedBranchCampaign(t)
+ defer done()
+ s4, err := f.q.CreateStep(context.Background(), gen.CreateStepParams{
+ WorkspaceID: f.ws, CampaignID: f.campaignID, StepOrder: 4, Subject: "S4", BodyText: "four",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.branch(t, f.steps[0], "always", 0, &f.steps[2], nil)
+ f.branch(t, f.steps[2], "always", 0, &f.steps[1], nil)
+ f.branch(t, f.steps[1], "always", 0, &s4.ID, nil)
+
+ for range 4 {
+ f.advance(t)
+ f.due(t)
+ }
+ f.requireSent(t, "S1", "S3", "S2", "S4")
+
+ var step2MessageID string
+ if err := f.pool.QueryRow(context.Background(), `SELECT message_id FROM sends WHERE id = $1`, f.sendID(t, 2)).
+ Scan(&step2MessageID); err != nil {
+ t.Fatal(err)
+ }
+ if got := f.snd.sent[3].InReplyTo; got != step2MessageID {
+ t.Fatalf("step 4 In-Reply-To = %q, want step 2's %q (the latest send)", got, step2MessageID)
+ }
+}
+
+// A campaign with NO branches behaves exactly as before branching existed:
+// every job the control plane builds is the linear one (next step by order, the
+// following step's delay, last-step on the final step), nothing is ever parked,
+// and awaiting_condition_step is never written.
+func TestLinearCampaignUnchangedByBranching(t *testing.T) {
+ f, done := seedBranchCampaign(t)
+ defer done()
+ ctx := context.Background()
+ if _, err := f.pool.Exec(ctx, `UPDATE sequence_steps SET delay_seconds = 60 * step_order WHERE campaign_id = $1`, f.campaignID); err != nil {
+ t.Fatal(err)
+ }
+ want := []struct {
+ order, nextDelay int
+ last bool
+ }{{1, 120, false}, {2, 180, false}, {3, 0, true}}
+ for _, w := range want {
+ job, err := f.core.GetStepSendJob(ctx, f.eid, f.ws.String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if job.Skip || job.ConditionPending || job.StepOrder != w.order || job.NextDelaySeconds != w.nextDelay || job.LastStep != w.last {
+ t.Fatalf("linear job = order %d next %d last %v skip %v pending %v, want %+v",
+ job.StepOrder, job.NextDelaySeconds, job.LastStep, job.Skip, job.ConditionPending, w)
+ }
+ f.advance(t)
+ f.due(t)
+ }
+ f.requireSent(t, "S1", "S2", "S3")
+ e := f.enrollment(t)
+ if e.Status != "completed" || e.AwaitingConditionStep != nil {
+ t.Fatalf("linear enrollment = %s awaiting=%v", e.Status, e.AwaitingConditionStep)
+ }
+ if job, err := f.core.GetStepSendJob(ctx, f.eid, f.ws.String()); err != nil || !job.Skip {
+ t.Fatalf("completed enrollment job = %+v, %v", job, err)
+ }
+}
+
+// The workspace pin holds on the graph path too: a foreign workspace id finds
+// no enrollment, so nothing is routed, parked or finished.
+func TestBranchRoutingIsWorkspacePinned(t *testing.T) {
+ f, done := seedBranchCampaign(t)
+ defer done()
+ f.branch(t, f.steps[0], "always", 0, nil, nil)
+ if _, err := f.core.GetStepSendJob(context.Background(), f.eid, uuid.NewString()); err == nil {
+ t.Fatal("a foreign workspace must not resolve the enrollment")
+ }
+ if e := f.enrollment(t); e.Status != "active" || e.CurrentStep != 0 {
+ t.Fatalf("foreign-workspace advance touched the enrollment: %s at %d", e.Status, e.CurrentStep)
+ }
+}
diff --git a/internal/worker/sequence/branchwait_test.go b/internal/worker/sequence/branchwait_test.go
new file mode 100644
index 00000000..39db9bc4
--- /dev/null
+++ b/internal/worker/sequence/branchwait_test.go
@@ -0,0 +1,66 @@
+package sequence
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/inroad/inroad/internal/coreapi"
+ "github.com/inroad/inroad/internal/platform/metrics"
+)
+
+// A pending branch condition schedules the next look at exactly RecheckAt and
+// does nothing else: no claim, no send, no stop. The job also carries Skip (for
+// workers that predate ConditionPending), so this proves ConditionPending is
+// checked first — with Skip winning, nothing would be enqueued.
+func TestAdvanceConditionPendingSchedulesRecheck(t *testing.T) {
+ at := time.Date(2026, 9, 24, 14, 3, 7, 0, time.UTC)
+ core := &stubCore{job: coreapi.StepSendJob{ConditionPending: true, RecheckAt: at, Skip: true}}
+ snd, enq := &fakeSender{}, &fakeEnq{}
+ if err := run(t, core, snd, enq); err != nil {
+ t.Fatal(err)
+ }
+ if !enq.atCalled || !enq.at.Equal(at) {
+ t.Fatalf("recheck enqueued=%v at=%v, want %v", enq.atCalled, enq.at, at)
+ }
+ if snd.called() || core.claimCalls != 0 || core.stopped != "" || core.finalized != nil || enq.inCalled {
+ t.Fatal("a pending condition must not claim, send, stop or back off")
+ }
+}
+
+// A condition wait is not a send outcome: it recurs hourly for every waiting
+// enrollment, and counting it as result="deferred" would drown the capacity
+// and limit defers that bucket is for. No inroad_sends_total series moves.
+func TestAdvanceConditionPendingRecordsNoSendMetric(t *testing.T) {
+ mtx := metrics.New()
+ core := &stubCore{job: coreapi.StepSendJob{ConditionPending: true, RecheckAt: time.Now(), Skip: true}}
+ if err := runWithMetrics(t, core, &fakeSender{}, &fakeEnq{}, mtx); err != nil {
+ t.Fatal(err)
+ }
+ for _, result := range []string{"sent", "failed", "deferred", "skipped"} {
+ if n := sendsCount(t, mtx, result); n != 0 {
+ t.Errorf("result=%s = %v, want 0", result, n)
+ }
+ }
+}
+
+// An enqueue failure is returned so asynq retries: the control plane already
+// stamped next_due_at, so even a lost retry is re-driven by the sweeper, but
+// swallowing the error would hide a Redis outage.
+func TestAdvanceConditionPendingEnqueueErrorIsReturned(t *testing.T) {
+ core := &stubCore{job: coreapi.StepSendJob{ConditionPending: true, RecheckAt: time.Now(), Skip: true}}
+ enq := &failingAtEnq{err: errors.New("redis down")}
+ if err := run(t, core, &fakeSender{}, enq); err == nil {
+ t.Fatal("an enqueue failure must surface")
+ }
+}
+
+type failingAtEnq struct {
+ fakeEnq
+ err error
+}
+
+func (f *failingAtEnq) EnqueueAdvanceAt(context.Context, string, string, time.Time) error {
+ return f.err
+}
diff --git a/web/e2e/campaign-sequence-canvas.spec.ts b/web/e2e/campaign-sequence-canvas.spec.ts
index 7d54745c..3b0223a1 100644
--- a/web/e2e/campaign-sequence-canvas.spec.ts
+++ b/web/e2e/campaign-sequence-canvas.spec.ts
@@ -28,10 +28,13 @@ const TWO_STEPS: Step[] = [
async function mockApi(
page: Page,
initialSteps: Step[] = TWO_STEPS,
-): Promise<{ reorders: string[][]; creates: unknown[] }> {
+): Promise<{ reorders: string[][]; creates: unknown[]; branchWrites: unknown[] }> {
const reorders: string[][] = []
const creates: unknown[] = []
+ const branchWrites: unknown[] = []
let steps: Step[] = initialSteps
+ // Branches by source step, as the server stores them: one per step.
+ const branches = new Map>()
const membership = { workspace_id: 'workspace-e2e', workspace_name: 'Atlas Labs', role: 'owner' }
await page.route('**/api/v1/**', async (route: Route) => {
@@ -63,6 +66,51 @@ async function mockApi(
}
if (path.endsWith('/variants')) return route.fulfill(json([]))
+ if (path.endsWith('/reply-labels')) {
+ const label = (key: string, name: string, stops: boolean) => ({
+ id: `rl-${key}`,
+ key,
+ label: name,
+ color: '#888888',
+ position: 0,
+ is_builtin: true,
+ stops_enrollment: stops,
+ is_automated: false,
+ suppresses_contact: false,
+ captures_deal: false,
+ defers_enrollment: false,
+ created_at: '2026-09-01T00:00:00Z',
+ updated_at: '2026-09-01T00:00:00Z',
+ })
+ return route.fulfill(json({ labels: [label('interested', 'Interested', true), label('question', 'Question', false)] }))
+ }
+ if (path.endsWith(`/campaigns/${CAMPAIGN_ID}/graph`)) {
+ return route.fulfill(
+ json({
+ campaign_id: CAMPAIGN_ID,
+ entry_step_id: steps[0]?.id ?? null,
+ nodes: steps.map((step, index) => ({
+ step_id: step.id,
+ step_order: step.step_order,
+ default_next_step_id: steps[index + 1]?.id ?? null,
+ branch: branches.get(step.id) ?? null,
+ })),
+ }),
+ )
+ }
+ const branchPath = /\/steps\/([^/]+)\/branch$/.exec(path)
+ if (branchPath?.[1]) {
+ const stepId = branchPath[1]
+ if (request.method() === 'DELETE') {
+ branches.delete(stepId)
+ return route.fulfill({ status: 204, body: '' })
+ }
+ const body = request.postDataJSON() as Record
+ branchWrites.push(body)
+ const saved = { step_id: stepId, updated_at: '2026-09-23T00:00:00Z', ...body }
+ branches.set(stepId, saved)
+ return route.fulfill(json(saved))
+ }
if (path.endsWith(`/campaigns/${CAMPAIGN_ID}/steps/reorder`)) {
const { step_ids } = request.postDataJSON() as { step_ids: string[] }
reorders.push(step_ids)
@@ -104,7 +152,7 @@ async function mockApi(
return route.fulfill({ status: 404, contentType: 'application/json', body: '{"error":"unhandled e2e route"}' })
})
- return { reorders, creates }
+ return { reorders, creates, branchWrites }
}
async function signIn(page: Page) {
@@ -206,3 +254,62 @@ test('dragging from a step’s exit onto another step makes it next', async ({ p
await expect.poll(() => reorders.at(-1)).toEqual(['step-1', 'step-3', 'step-2'])
await expect(canvas.getByRole('button', { name: 'Edit step 2: Last call' })).toBeVisible()
})
+
+test('adding a condition draws the IF after its step, with Yes / No exits routed where they were set', async ({ page }) => {
+ const three: Step[] = [
+ { ...TWO_STEPS[0]!, body_html: '
hello
' },
+ TWO_STEPS[1]!,
+ { id: 'step-3', step_order: 3, delay_seconds: 86400, subject: 'Last call', body_text: 'closing', body_html: '' },
+ ]
+ const { branchWrites } = await mockApi(page, three)
+ await signIn(page)
+ await page.goto(`/app/campaigns/${CAMPAIGN_ID}/steps`)
+ const canvas = page.getByRole('region', { name: 'Sequence flow' })
+ await expect(canvas.getByText('Last call')).toBeVisible()
+
+ await canvas.getByRole('button', { name: 'Add a condition after step 1' }).click()
+ const panel = page.getByRole('complementary', { name: 'Condition after step 1' })
+ await panel.getByLabel('If they…').selectOption('opened')
+ await panel.getByLabel('Within (days)').fill('3')
+ await panel.getByLabel('Yes — go to').selectOption('step-3')
+ await panel.getByLabel('No — go to').selectOption('step-2')
+ await panel.getByRole('button', { name: 'Add condition' }).click()
+
+ await expect.poll(() => branchWrites.at(-1)).toEqual({
+ condition: 'opened',
+ within_days: 3,
+ reply_label_key: null,
+ yes_step_id: 'step-3',
+ no_step_id: 'step-2',
+ // A new condition is create-only.
+ expected_updated_at: null,
+ })
+
+ // The diamond, in words, right after step 1 — and it took focus.
+ const condition = canvas.getByRole('button', { name: 'Edit the condition after step 1: Opened within 3 days' })
+ await expect(condition).toBeVisible()
+ await expect(condition).toBeFocused()
+ await expect(canvas.locator('.react-flow__node[data-id="cond:step-1"] polygon')).toHaveCount(1)
+ // Labelled exits, each an edge to the step it was set to.
+ const node = canvas.locator('.react-flow__node[data-id="cond:step-1"]')
+ await expect(node.getByText('Yes', { exact: true })).toBeVisible()
+ await expect(node.getByText('No', { exact: true })).toBeVisible()
+ await expect(canvas.getByTestId('rf__edge-cond:step-1:yes->step-3')).toHaveCount(1)
+ await expect(canvas.getByTestId('rf__edge-cond:step-1:no->step-2')).toHaveCount(1)
+
+ // Dragging the Yes exit onto step 2 re-routes it — a real pointer drag.
+ const from = node.locator('.react-flow__handle.source').first()
+ const to = canvas.locator('.react-flow__node[data-id="step-2"] .react-flow__handle.target')
+ const [a, b] = await Promise.all([from.boundingBox(), to.boundingBox()])
+ if (!a || !b) throw new Error('handles not rendered')
+ await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
+ await page.mouse.down()
+ await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
+ await page.mouse.up()
+ // The drag is conditional on the version the canvas showed — echoed verbatim.
+ await expect.poll(() => branchWrites.at(-1)).toMatchObject({
+ yes_step_id: 'step-2',
+ no_step_id: 'step-2',
+ expected_updated_at: '2026-09-23T00:00:00Z',
+ })
+})
diff --git a/web/src/components/shared/flow/__tests__/flow-layout.test.ts b/web/src/components/shared/flow/__tests__/flow-layout.test.ts
index 0bc1b12d..c501d582 100644
--- a/web/src/components/shared/flow/__tests__/flow-layout.test.ts
+++ b/web/src/components/shared/flow/__tests__/flow-layout.test.ts
@@ -1,5 +1,6 @@
import { renderHook } from '@testing-library/react'
-import type { Edge, Node } from '@xyflow/react'
+import type { Node, XYPosition } from '@xyflow/react'
+import type { FlowEdgeType } from '../flow-edge'
import { describe, expect, test } from 'vitest'
import { layoutFlow, useFlowLayout } from '../flow-layout'
import type { FlowNodeSize } from '../node-registry'
@@ -16,17 +17,58 @@ const sizeOf = (type: string | undefined): FlowNodeSize => {
}
const node = (id: string, type: string): Node => ({ id, type, position: { x: 0, y: 0 }, data: {} })
-const edge = (source: string, target: string, sourceHandle?: string): Edge => ({
- id: `${source}->${target}`,
+const edge = (source: string, target: string, sourceHandle?: string): FlowEdgeType => ({
+ id: `${source}:${sourceHandle ?? ''}->${target}`,
source,
target,
sourceHandle,
})
+type Laid = ReturnType>
+
+/** An edge as drawn: from its source's exit, through any route, to its target's entry. */
+function drawn(laid: Laid, drawnEdge: FlowEdgeType): XYPosition[] {
+ const byId = new Map(laid.nodes.map((n) => [n.id, n]))
+ const source = byId.get(drawnEdge.source)
+ const target = byId.get(drawnEdge.target)
+ if (!source || !target) return []
+ const exit = drawnEdge.sourceHandle === 'yes' ? 1 / 3 : drawnEdge.sourceHandle === 'no' ? 2 / 3 : 1 / 2
+ const from = { x: source.position.x + (source.width ?? 0) * exit, y: source.position.y + (source.height ?? 0) }
+ const to = { x: target.position.x + (target.width ?? 0) / 2, y: target.position.y }
+ return [from, ...(drawnEdge.data?.route ?? []), to]
+}
+
+/** Where an exit heads first below its node: its first bend, or its target. */
+function firstStepX(laid: Laid, exitEdge: FlowEdgeType): number {
+ return drawn(laid, exitEdge)[1]?.x ?? Number.NaN
+}
+
+function segmentsCross([p1, p2]: [XYPosition, XYPosition], [p3, p4]: [XYPosition, XYPosition]): boolean {
+ const side = (a: XYPosition, b: XYPosition, c: XYPosition) => (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)
+ return side(p3, p4, p1) * side(p3, p4, p2) < 0 && side(p1, p2, p3) * side(p1, p2, p4) < 0
+}
+
+/** Every pair of drawn edges that properly cross (touching at a shared node doesn't count). */
+function crossings(laid: Laid): string[] {
+ const lines = laid.edges.map((e) => ({ id: e.id, points: drawn(laid, e) }))
+ const found: string[] = []
+ lines.forEach((a, i) => {
+ for (const b of lines.slice(i + 1)) {
+ const hit = a.points.slice(1).some((end, k) =>
+ b.points.slice(1).some((otherEnd, m) =>
+ segmentsCross([a.points[k] ?? end, end], [b.points[m] ?? otherEnd, otherEnd]),
+ ),
+ )
+ if (hit) found.push(`${a.id} × ${b.id}`)
+ }
+ })
+ return found
+}
+
describe('layoutFlow', () => {
test('stacks a chain top to bottom, centred on one axis, at each node’s declared size', () => {
const nodes = [node('a', 'trigger'), node('b', 'action'), node('c', 'action')]
- const laid = layoutFlow(nodes, [edge('a', 'b'), edge('b', 'c')], { sizeOf })
+ const laid = layoutFlow(nodes, [edge('a', 'b'), edge('b', 'c')], { sizeOf }).nodes
const [a, b, c] = laid
expect(a && b && c).toBeTruthy()
@@ -43,7 +85,7 @@ describe('layoutFlow', () => {
test('puts two branches side by side in one rank, first-emitted edge on the left', () => {
const nodes = [node('if', 'condition'), node('yes', 'action'), node('no', 'action')]
- const laid = layoutFlow(nodes, [edge('if', 'yes', 'yes'), edge('if', 'no', 'no')], { sizeOf })
+ const laid = layoutFlow(nodes, [edge('if', 'yes', 'yes'), edge('if', 'no', 'no')], { sizeOf }).nodes
const byId = new Map(laid.map((n) => [n.id, n]))
const yes = byId.get('yes')
const no = byId.get('no')
@@ -53,14 +95,103 @@ describe('layoutFlow', () => {
test('the branch order follows the edges, not dagre’s own choice', () => {
const nodes = [node('if', 'condition'), node('yes', 'action'), node('no', 'action')]
- const laid = layoutFlow(nodes, [edge('if', 'no', 'no'), edge('if', 'yes', 'yes')], { sizeOf })
+ const laid = layoutFlow(nodes, [edge('if', 'no', 'no'), edge('if', 'yes', 'yes')], { sizeOf }).nodes
const byId = new Map(laid.map((n) => [n.id, n]))
expect((byId.get('no')?.position.x ?? 0) + 200).toBeLessThanOrEqual(byId.get('yes')?.position.x ?? 0)
})
+ test('an edge that skips a rank is routed round the nodes in between; adjacent ones are not', () => {
+ // The long s → b1 → b2 → c path fixes the ranks, so one of the edges on the
+ // short s → a → c path has to span more than one (dagre picks which).
+ const nodes = ['s', 'a', 'b1', 'b2', 'c'].map((id) => node(id, 'action'))
+ const laid = layoutFlow(
+ nodes,
+ [edge('s', 'a'), edge('s', 'b1'), edge('b1', 'b2'), edge('b2', 'c'), edge('a', 'c')],
+ { sizeOf },
+ )
+ const routed = laid.edges.filter((e) => e.data?.route)
+ expect(routed.map((e) => e.id)).toHaveLength(1)
+ expect(laid.edges.find((e) => e.id === 'b1:->b2')?.data?.route).toBeUndefined()
+ // No bend lands inside any node's box.
+ for (const point of routed.flatMap((e) => e.data?.route ?? [])) {
+ for (const n of laid.nodes) {
+ const inside =
+ point.x > n.position.x &&
+ point.x < n.position.x + (n.width ?? 0) &&
+ point.y > n.position.y &&
+ point.y < n.position.y + (n.height ?? 0)
+ expect(inside, `bend ${point.x},${point.y} inside ${n.id}`).toBe(false)
+ }
+ }
+ expect(crossings(laid)).toEqual([])
+ })
+
+ test('a multi-exit node keeps its exits in edge order even when one skips a rank', () => {
+ // Yes jumps past b to c; No goes straight to b. Uncrossed means the yes
+ // path stays left of b the whole way, and b sits right of the fork.
+ const nodes = [node('if', 'condition'), node('b', 'action'), node('c', 'action')]
+ const laid = layoutFlow(nodes, [edge('if', 'c', 'yes'), edge('if', 'b', 'no'), edge('b', 'c')], { sizeOf })
+ const b = laid.nodes.find((n) => n.id === 'b')
+ const yesRoute = laid.edges.find((e) => e.id === 'if:yes->c')?.data?.route ?? []
+ const passingB = yesRoute.filter((point) => point.y >= (b?.position.y ?? 0) && point.y <= (b?.position.y ?? 0) + 80)
+ expect(passingB.length).toBeGreaterThan(0)
+ for (const point of passingB) expect(point.x).toBeLessThan(b?.position.x ?? 0)
+ expect(crossings(laid)).toEqual([])
+ })
+
+ test('two conditions, a converging path and a backward jump: no Yes/No inversion, no crossings', () => {
+ // 1 → IF(yes 4, no 2); 2 → 3 → IF(yes 5, no 4) — both conditions converge on 4
+ // 4 → ALWAYS → end; 5 → 6 → ALWAYS(back to 4) — a jump backwards in order
+ const nodes = [
+ node('s1', 'action'),
+ node('c1', 'condition'),
+ node('s2', 'action'),
+ node('s3', 'action'),
+ node('c3', 'condition'),
+ node('s4', 'action'),
+ node('a4', 'trigger'),
+ node('end', 'trigger'),
+ node('s5', 'action'),
+ node('s6', 'action'),
+ node('a6', 'trigger'),
+ ]
+ const edges = [
+ edge('s1', 'c1'),
+ edge('c1', 's4', 'yes'),
+ edge('c1', 's2', 'no'),
+ edge('s2', 's3'),
+ edge('s3', 'c3'),
+ edge('c3', 's5', 'yes'),
+ edge('c3', 's4', 'no'),
+ edge('s4', 'a4'),
+ edge('a4', 'end', 'yes'),
+ edge('s5', 's6'),
+ edge('s6', 'a6'),
+ edge('a6', 's4', 'yes'),
+ ]
+ const laid = layoutFlow(nodes, edges, { sizeOf })
+ for (const condition of ['c1', 'c3']) {
+ const [yes, no] = ['yes', 'no'].map((handle) => {
+ const exit = laid.edges.find((e) => e.source === condition && e.sourceHandle === handle)
+ return exit ? firstStepX(laid, exit) : Number.NaN
+ })
+ expect(yes, `${condition}: yes leaves left of no`).toBeLessThan(no ?? Number.NaN)
+ }
+ expect(crossings(laid)).toEqual([])
+ })
+
+ test('two edges between the same pair are both kept', () => {
+ const { edges } = layoutFlow(
+ [node('if', 'condition'), node('x', 'action')],
+ [edge('if', 'x', 'yes'), edge('if', 'x', 'no')],
+ { sizeOf },
+ )
+ expect(edges.map((e) => e.id)).toEqual(['if:yes->x', 'if:no->x'])
+ })
+
test('does not mutate its input and ignores edges to unknown nodes', () => {
const nodes = [node('a', 'trigger')]
- const laid = layoutFlow(nodes, [edge('a', 'ghost')], { sizeOf })
+ const laid = layoutFlow(nodes, [edge('a', 'ghost')], { sizeOf }).nodes
expect(nodes[0]?.position).toEqual({ x: 0, y: 0 })
expect(nodes[0]?.width).toBeUndefined()
expect(laid).toHaveLength(1)
@@ -69,7 +200,7 @@ describe('layoutFlow', () => {
test('LR direction lays ranks out horizontally', () => {
const laid = layoutFlow([node('a', 'trigger'), node('b', 'action')], [edge('a', 'b')], { sizeOf }, {
direction: 'LR',
- })
+ }).nodes
expect(laid[1]?.position.x).toBeGreaterThan((laid[0]?.position.x ?? 0) + 100)
})
})
@@ -80,7 +211,7 @@ describe('useFlowLayout', () => {
const shapes = { sizeOf }
const nodes = [node('a', 'trigger'), node('b', 'action')]
const edges = [edge('a', 'b')]
- const { result, rerender } = renderHook(({ n, e }) => useFlowLayout(n, e, shapes), {
+ const { result, rerender } = renderHook(({ n, e }) => useFlowLayout(n, e, shapes).nodes, {
initialProps: { n: nodes, e: edges },
})
const first = result.current
diff --git a/web/src/components/shared/flow/flow-edge.tsx b/web/src/components/shared/flow/flow-edge.tsx
index 0189f351..cc7a9a55 100644
--- a/web/src/components/shared/flow/flow-edge.tsx
+++ b/web/src/components/shared/flow/flow-edge.tsx
@@ -1,4 +1,12 @@
-import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, type Edge, type EdgeProps } from '@xyflow/react'
+import {
+ BaseEdge,
+ EdgeLabelRenderer,
+ getSmoothStepPath,
+ type Edge,
+ type EdgeProps,
+ type Position,
+ type XYPosition,
+} from '@xyflow/react'
import { AddNodeButton } from './add-node-button'
import { useFlowInsert } from './flow-insert-context'
@@ -7,6 +15,11 @@ export type FlowEdgeData = {
label?: string
/** Present = the edge offers an add button; the text is that button's accessible name. */
insertLabel?: string
+ /**
+ * Bend points the layout routed the edge through (see `layoutFlow`), for an
+ * edge that skips a rank and would otherwise be drawn behind a node.
+ */
+ route?: readonly XYPosition[]
}
export type FlowEdgeType = Edge
@@ -30,14 +43,12 @@ export function FlowEdge({
data,
}: EdgeProps) {
const onInsert = useFlowInsert()
- const [path, labelX, labelY] = getSmoothStepPath({
- sourceX,
- sourceY,
- targetX,
- targetY,
+ const [path, labelX, labelY] = edgePath({
+ from: { x: sourceX, y: sourceY },
+ to: { x: targetX, y: targetY },
+ route: data?.route ?? [],
sourcePosition,
targetPosition,
- borderRadius: 10,
})
const insertLabel = data?.insertLabel
const canInsert = insertLabel !== undefined && onInsert !== null
@@ -68,3 +79,43 @@ export function FlowEdge({
>
)
}
+
+/**
+ * The edge's path and where its chip sits. Without a route it is one rounded
+ * orthogonal step; with one, a step through each bend point in turn (a path of
+ * several subpaths reads as one line, and the arrowhead lands on the last
+ * vertex), with the chip on the middle bend so it sits in the gap the route
+ * found rather than on a node.
+ */
+function edgePath({
+ from,
+ to,
+ route,
+ sourcePosition,
+ targetPosition,
+}: {
+ from: XYPosition
+ to: XYPosition
+ route: readonly XYPosition[]
+ sourcePosition: Position
+ targetPosition: Position
+}): [path: string, labelX: number, labelY: number] {
+ const waypoints = [from, ...route, to]
+ const segments = waypoints.slice(1).map((end, index) => {
+ const start = waypoints[index] ?? from
+ return getSmoothStepPath({
+ sourceX: start.x,
+ sourceY: start.y,
+ targetX: end.x,
+ targetY: end.y,
+ sourcePosition,
+ targetPosition,
+ borderRadius: 10,
+ })
+ })
+ const path = segments.map(([segment]) => segment).join(' ')
+ const middle = route[Math.floor(route.length / 2)]
+ if (middle) return [path, middle.x, middle.y]
+ const [, labelX, labelY] = segments[0] ?? ['', from.x, from.y]
+ return [path, labelX, labelY]
+}
diff --git a/web/src/components/shared/flow/flow-layout.ts b/web/src/components/shared/flow/flow-layout.ts
index eca9a14d..e450b133 100644
--- a/web/src/components/shared/flow/flow-layout.ts
+++ b/web/src/components/shared/flow/flow-layout.ts
@@ -1,6 +1,7 @@
import { useMemo } from 'react'
import { graphlib, layout } from '@dagrejs/dagre'
-import type { Edge, Node } from '@xyflow/react'
+import type { Node, XYPosition } from '@xyflow/react'
+import type { FlowEdgeType } from './flow-edge'
import type { FlowNodeRegistry, FlowNodeSize } from './node-registry'
/** The part of a registry layout needs; `handlesOf` is optional so a test can lay out bare boxes. */
@@ -15,46 +16,66 @@ export type FlowLayoutOptions = {
rankSpacing?: number
}
+export type FlowLayout = { nodes: N[]; edges: FlowEdgeType[] }
+
const DEFAULTS: Required = { direction: 'TB', nodeSpacing: 56, rankSpacing: 72 }
+// dagre writes each node's centre (`x`, `y`) back onto the label we give it,
+// and each edge's route (`points`) onto its label.
+type PlacedNode = FlowNodeSize & { x?: number; y?: number }
+type PlacedEdge = { points?: XYPosition[] }
+
/**
- * Positions every node with dagre and returns new nodes; the inputs are not
- * touched. Positions are React Flow's top-left corner (dagre reports centres),
- * and each node gets its declared `width`/`height` (and handle geometry) so
- * React Flow renders it at the size the layout assumed instead of waiting to
- * measure it.
+ * Positions every node with dagre and returns new nodes and edges; the inputs
+ * are not touched. Positions are React Flow's top-left corner (dagre reports
+ * centres), and each node gets its declared `width`/`height` (and handle
+ * geometry) so React Flow renders it at the size the layout assumed instead of
+ * waiting to measure it.
+ *
+ * An edge that skips a rank (a branch jumping from step 1 to step 3) gets
+ * dagre's bend points as `data.route`. Drawn straight, it would run behind the
+ * node in between and read as going there; dagre routes it through the gap
+ * beside that node instead.
*
- * A node's children keep the order of its edges: emit a condition's "yes"
- * edge before its "no" edge and "yes" lands on the left. dagre's own crossing
- * minimisation would otherwise pick either side, so this is passed to it as an
- * explicit ordering constraint.
+ * A node with several exits keeps them in the order of its edges: emit a
+ * condition's "yes" edge before its "no" edge and the yes path stays on the
+ * left all the way down. That order comes from dagre's INITIAL ordering — a
+ * depth-first walk that visits each node's edges in insertion order — which is
+ * kept as-is (`disableOptimalOrderHeuristic`). dagre 3's crossing-reduction
+ * sweeps are what would otherwise run, and they re-crossed a condition's exits
+ * (yes → a far step, no → the next one); its `constraints` option can't
+ * prevent that, because it only orders nodes within one rank and a skipping
+ * exit has no node there (and with the sweeps off, dagre never reads
+ * constraints at all). The flows drawn here are near-trees, where the walk
+ * order is already crossing-free; the layout tests pin that for two
+ * conditions, a converging path and a backward jump.
*/
export function layoutFlow(
nodes: readonly N[],
- edges: readonly Edge[],
+ edges: readonly FlowEdgeType[],
{ sizeOf, handlesOf }: FlowNodeShapes,
options: FlowLayoutOptions = {},
-): N[] {
+): FlowLayout {
// `??` per field, not an object spread: an explicit `undefined` must fall back
// to the default rather than override it.
const direction = options.direction ?? DEFAULTS.direction
const nodeSpacing = options.nodeSpacing ?? DEFAULTS.nodeSpacing
const rankSpacing = options.rankSpacing ?? DEFAULTS.rankSpacing
- // dagre writes each node's centre (`x`, `y`) back onto the label we give it.
- const graph = new graphlib.Graph