From 5f685420d5fd8b4fda2973220ea3542a627f7e9f Mon Sep 17 00:00:00 2001 From: Ahmustufa Date: Wed, 23 Sep 2026 19:32:32 +0500 Subject: [PATCH 1/6] feat(sequences): conditional branching for campaign steps A step can now carry one branch that routes each enrollment after the step is sent: always / opened / clicked / replied / not_opened / not_replied, within N days (1-90), optionally narrowed to a reply label, with a yes exit and a no exit (a null exit ends the path). Steps without a branch keep the original linear code path unchanged, and a test pins that. - New table sequence_step_branches, one row per source step. Composite FKs keep both exits in the same campaign and tenant. Deleting a target step nulls only that exit. - The route is recomputed from events inside a fixed window measured from the step's own send, so a decided answer can't flip. Opens and clicks count human events only. Replies look at inbox_messages (the other leg) and exclude auto-replies unless the branch names that label. - Cycles are refused at save time under a per-campaign lock, including for step delete and reorder. A runtime backstop ends the path if a loop ever gets in. - Mid-flight edits use the graph as it is when the decision is made. The rule is documented in branchroute.go. - GET /campaigns/{id}/graph, PUT/DELETE /campaigns/{id}/steps/{stepId}/branch. - Threading replies to the most recently sent email, not the highest-numbered step, since a path can go 1 -> 3 -> 2. Co-Authored-By: Claude Opus 5.5 (1M context) --- api/openapi.yaml | 161 ++++++ cmd/inroad/main.go | 2 +- docs/src/content/docs/security.md | 41 +- internal/app/enrollment/service.go | 14 + internal/app/enrollment/service_test.go | 33 ++ internal/app/enrollment/store.go | 16 + internal/app/sequencestep/branch.go | 167 ++++++ .../sequencestep/branch_integration_test.go | 287 ++++++++++ internal/app/sequencestep/branch_test.go | 312 +++++++++++ internal/app/sequencestep/branchhandler.go | 225 ++++++++ .../app/sequencestep/branchhandler_test.go | 53 ++ internal/app/sequencestep/branchstore.go | 171 ++++++ internal/app/sequencestep/handler.go | 7 + .../sequencestep/reorder_integration_test.go | 16 +- internal/app/sequencestep/routes.go | 9 + internal/app/sequencestep/service.go | 17 +- internal/app/sequencestep/service_test.go | 27 +- internal/app/sequencestep/store.go | 82 +-- internal/app/sequencestep/variant_test.go | 2 +- internal/coreapi/coreapi.go | 12 + internal/coreapi/inprocess/branchroute.go | 410 +++++++++++++++ .../coreapi/inprocess/branchroute_test.go | 303 +++++++++++ internal/coreapi/inprocess/inboxpoll.go | 13 +- internal/coreapi/inprocess/stepsendjob.go | 70 ++- internal/platform/db/gen/enrollment.sql.go | 81 ++- internal/platform/db/gen/models.go | 52 +- internal/platform/db/gen/stepbranch.sql.go | 274 ++++++++++ internal/platform/db/gen/stepsend.sql.go | 83 +-- ...0923110214_sequence_step_branches.down.sql | 7 + ...260923110214_sequence_step_branches.up.sql | 113 ++++ internal/platform/db/queries/enrollment.sql | 43 ++ internal/platform/db/queries/stepbranch.sql | 89 ++++ internal/platform/db/queries/stepsend.sql | 9 +- internal/platform/seqgraph/seqgraph.go | 492 ++++++++++++++++++ internal/platform/seqgraph/seqgraph_test.go | 354 +++++++++++++ internal/worker/sequence/advance.go | 16 + .../sequence/branching_integration_test.go | 460 ++++++++++++++++ internal/worker/sequence/branchwait_test.go | 49 ++ web/src/store/api.ts | 100 ++++ 39 files changed, 4521 insertions(+), 151 deletions(-) create mode 100644 internal/app/sequencestep/branch.go create mode 100644 internal/app/sequencestep/branch_integration_test.go create mode 100644 internal/app/sequencestep/branch_test.go create mode 100644 internal/app/sequencestep/branchhandler.go create mode 100644 internal/app/sequencestep/branchhandler_test.go create mode 100644 internal/app/sequencestep/branchstore.go create mode 100644 internal/coreapi/inprocess/branchroute.go create mode 100644 internal/coreapi/inprocess/branchroute_test.go create mode 100644 internal/platform/db/gen/stepbranch.sql.go create mode 100644 internal/platform/db/migrations/20260923110214_sequence_step_branches.down.sql create mode 100644 internal/platform/db/migrations/20260923110214_sequence_step_branches.up.sql create mode 100644 internal/platform/db/queries/stepbranch.sql create mode 100644 internal/platform/seqgraph/seqgraph.go create mode 100644 internal/platform/seqgraph/seqgraph_test.go create mode 100644 internal/worker/sequence/branching_integration_test.go create mode 100644 internal/worker/sequence/branchwait_test.go diff --git a/api/openapi.yaml b/api/openapi.yaml index 9ec1f41d..7c9ac8fc 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,99 @@ 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 + count HUMAN tracking events only (machine prefetches are ignored); + replied / not_replied count inbound replies from the contact on this + campaign, excluding automated ones (out-of-office, auto-reply) unless + reply_label_key names one explicitly. A reply whose label stops the + enrollment (the default for every human label) still stops it - a + branch never overrides a reply label's automation - so a replied branch + acts on replies whose label has stops_enrollment=false. + + + 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. + 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 branch (codes invalid_condition, invalid_within_days, invalid_reply_label, 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 } + '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. 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. + responses: + '204': { description: Removed (or there was none) } + '401': { description: Unauthorized } + '403': { description: Insufficient scope (campaigns:write) } + '404': { description: Campaign or step not found } + '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 +5140,68 @@ 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" } + 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 } + 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. 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 } + 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, no_exit_not_allowed, unknown_step, unknown_target, cycle] + step_ids: + type: array + items: { type: string, format: uuid } + description: "code cycle only: the steps on the loop, in path order" 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/security.md b/docs/src/content/docs/security.md index ac215f1a..bf69a2b8 100644 --- a/docs/src/content/docs/security.md +++ b/docs/src/content/docs/security.md @@ -2269,21 +2269,36 @@ write history that never happened. at least one dot) rather than stripping characters: a name carrying CRLF would be SMTP command injection. Anything that does not validate yields `""` and go-mail's default is kept. +## Conditional branching (sequence step routing) +86. **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. + + **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. + + **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. ## 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..e32e0b11 --- /dev/null +++ b/internal/app/sequencestep/branch.go @@ -0,0 +1,167 @@ +package sequencestep + +import ( + "context" + "fmt" + "slices" + + "github.com/google/uuid" + + "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 +} + +// 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.checker.CampaignStatus(ctx, ws, campaignID); err != nil { + return Graph{}, ErrCampaignNotFound + } + 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 and label 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. +func (s *Service) SetBranch(ctx context.Context, ws, campaignID uuid.UUID, in BranchInput) (gen.SequenceStepBranch, error) { + if _, err := s.checker.CampaignStatus(ctx, ws, campaignID); err != nil { + return gen.SequenceStepBranch{}, ErrCampaignNotFound + } + if err := s.assertStepInCampaign(ctx, ws, campaignID, in.StepID); 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", + } + } + if err := branchModel(in).ValidateShape(); err != nil { + return gen.SequenceStepBranch{}, err + } + if in.ReplyLabelKey != nil { + ok, err := s.branches.ReplyLabelExists(ctx, ws, *in.ReplyLabelKey) + if err != nil { + return gen.SequenceStepBranch{}, fmt.Errorf("check reply label: %w", err) + } + if !ok { + return gen.SequenceStepBranch{}, &seqgraph.ShapeError{ + Code: seqgraph.CodeLabelNotAllowed, + Msg: fmt.Sprintf("reply label %q does not exist in this workspace", *in.ReplyLabelKey), + } + } + } + // 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) +} + +// DeleteBranch removes the router on one step, returning it to linear +// fall-through. Idempotent. Allowed live, for the reason SetBranch is; refused +// with a CycleError when the restored fall-through would close a loop. +func (s *Service) DeleteBranch(ctx context.Context, ws, campaignID, stepID uuid.UUID) error { + if _, err := s.checker.CampaignStatus(ctx, ws, campaignID); err != nil { + return ErrCampaignNotFound + } + if err := s.assertStepInCampaign(ctx, ws, campaignID, stepID); err != nil { + return err + } + return s.branches.DeleteBranch(ctx, ws, campaignID, stepID, 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..1bb1a27a --- /dev/null +++ b/internal/app/sequencestep/branch_integration_test.go @@ -0,0 +1,287 @@ +//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) + 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) + label := "positive" // seeded for every workspace by migration 000047 + 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]); 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) + } +} + +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], 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..01b528d7 --- /dev/null +++ b/internal/app/sequencestep/branch_test.go @@ -0,0 +1,312 @@ +package sequencestep + +import ( + "context" + "errors" + "testing" + + "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" +) + +// 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 map[string]bool + upserts int +} + +func (f *fakeBranchStore) ListBranches(context.Context, uuid.UUID, uuid.UUID) ([]gen.SequenceStepBranch, error) { + return f.list(), nil +} + +func (f *fakeBranchStore) ReplyLabelExists(_ context.Context, _ uuid.UUID, key string) (bool, error) { + return f.labels[key], nil +} + +func (f *fakeBranchStore) UpsertBranch(_ context.Context, ws uuid.UUID, in BranchInput, check GraphCheck) (gen.SequenceStepBranch, error) { + 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, check GraphCheck) error { + 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 + 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)}) + } + bs := &fakeBranchStore{steps: steps, labels: map[string]bool{"positive": true}} + store := &stepsByID{steps: steps} + return branchFixture{ + svc: NewService(store, fakeChecker{status: status}, &fakeVariantStore{}, bs), + branches: bs, 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: errors.New("no rows")}, &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) + } +} + +// 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 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]) + 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]); 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: errors.New("no rows")}, &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) + } +} diff --git a/internal/app/sequencestep/branchhandler.go b/internal/app/sequencestep/branchhandler.go new file mode 100644 index 00000000..1ca8501d --- /dev/null +++ b/internal/app/sequencestep/branchhandler.go @@ -0,0 +1,225 @@ +package sequencestep + +import ( + "encoding/json" + "errors" + "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"` +} + +// 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"` +} + +// 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 + } + // 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, + }) + 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 + } + if err := h.svc.DeleteBranch(r.Context(), ws, campaignID, stepID); 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`. +func writeGraphError(w http.ResponseWriter, err error, fallback string) { + var cycle *seqgraph.CycleError + switch code := seqgraph.CodeOf(err); { + 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, + UpdatedAt: b.UpdatedAt.Time.UTC().Format(time.RFC3339), + } + 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..60fba6b7 --- /dev/null +++ b/internal/app/sequencestep/branchhandler_test.go @@ -0,0 +1,53 @@ +package sequencestep + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + + "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) + } + }) + } +} diff --git a/internal/app/sequencestep/branchstore.go b/internal/app/sequencestep/branchstore.go new file mode 100644 index 00000000..42d291f2 --- /dev/null +++ b/internal/app/sequencestep/branchstore.go @@ -0,0 +1,171 @@ +package sequencestep + +import ( + "context" + "errors" + "fmt" + + "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 +} + +// 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) + ReplyLabelExists(ctx context.Context, ws uuid.UUID, key string) (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. + 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. + DeleteBranch(ctx context.Context, ws, campaignID, stepID uuid.UUID, 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) ReplyLabelExists(ctx context.Context, ws uuid.UUID, key string) (bool, error) { + return s.q.ReplyLabelKeyExists(ctx, gen.ReplyLabelKeyExistsParams{WorkspaceID: ws, Key: key}) +} + +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 := q.UpsertBranch(ctx, gen.UpsertBranchParams{ + 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), + }) + if errors.Is(err, pgx.ErrNoRows) { + return ErrBranchConflict + } + 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 +} + +func (s *PgBranchStore) DeleteBranch(ctx context.Context, ws, campaignID, stepID uuid.UUID, check GraphCheck) error { + return withGraphLock(ctx, s.pool, s.q, ws, campaignID, check, func(q *gen.Queries) error { + if err := q.DeleteBranch(ctx, gen.DeleteBranchParams{StepID: stepID, CampaignID: campaignID, WorkspaceID: ws}); err != nil { + return fmt.Errorf("delete branch: %w", err) + } + return nil + }) +} + +// 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} +} 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..105549b3 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 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..d6bb0bb9 100644 --- a/internal/coreapi/coreapi.go +++ b/internal/coreapi/coreapi.go @@ -730,6 +730,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..a8f30dca --- /dev/null +++ b/internal/coreapi/inprocess/branchroute.go @@ -0,0 +1,410 @@ +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 (at most +// conditionRecheckInterval away). A lengthened window keeps waiting; a +// shortened one decides at the next check. +// - 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, 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 is whether the row predates the cursor step's own +// send: a recover-forward row is created after it, a loop's row long before. +func (c client) revisits(ctx context.Context, ws uuid.UUID, b gen.GetStepEnrollmentBundleRow, target seqgraph.Step) (bool, error) { + created, err := c.q.StepSendCreatedAt(ctx, gen.StepSendCreatedAtParams{ + ID: deriveStepSendID(b.CampaignID, b.ContactID, int(target.Order)), WorkspaceID: ws, + }) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("step send lookup: %w", err) + } + // No tolerance, unlike the due checks: both instants are the DATABASE's + // now() (sends.created_at's default and the cursor advance's last_sent_at), + // so there is no clock skew to absorb — and a tolerance would blind this to + // exactly the loop that matters most, zero-delay steps a few seconds apart. + return created.Time.Before(b.LastSentAt.Time), 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 82). 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..c0332ff2 100644 --- a/internal/coreapi/inprocess/stepsendjob.go +++ b/internal/coreapi/inprocess/stepsendjob.go @@ -231,17 +231,39 @@ 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) { + 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) @@ -329,17 +351,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 diff --git a/internal/platform/db/gen/enrollment.sql.go b/internal/platform/db/gen/enrollment.sql.go index 65ca8d11..4d3ab4bd 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,41 @@ 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 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. +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..6327d453 --- /dev/null +++ b/internal/platform/db/gen/stepbranch.sql.go @@ -0,0 +1,274 @@ +// 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 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 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 82). 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 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 replyLabelKeyExists = `-- name: ReplyLabelKeyExists :one +SELECT EXISTS (SELECT 1 FROM reply_labels WHERE workspace_id = $1 AND key = $2)::bool +` + +type ReplyLabelKeyExistsParams struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + Key string `json:"key"` +} + +// Whether the workspace defines a reply label with this key. Save-time +// validation only: a branch naming a label that does not exist could never match. +func (q *Queries) ReplyLabelKeyExists(ctx context.Context, arg ReplyLabelKeyExistsParams) (bool, error) { + row := q.db.QueryRow(ctx, replyLabelKeyExists, arg.WorkspaceID, arg.Key) + var column_1 bool + err := row.Scan(&column_1) + return column_1, 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: a routed step whose row predates the enrollment's last +// send 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 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, updated_at = now() +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. +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..5bcce93f 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, @@ -222,7 +227,7 @@ 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 +ORDER BY sent_at DESC NULLS LAST, step_order DESC LIMIT 1 ` @@ -238,6 +243,12 @@ 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. func (q *Queries) LatestSentForContact(ctx context.Context, arg LatestSentForContactParams) (LatestSentForContactRow, error) { row := q.db.QueryRow(ctx, latestSentForContact, arg.CampaignID, arg.ContactID) var i LatestSentForContactRow 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..a080d200 --- /dev/null +++ b/internal/platform/db/migrations/20260923110214_sequence_step_branches.down.sql @@ -0,0 +1,7 @@ +DROP INDEX IF EXISTS idx_inbox_threads_campaign_contact; + +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..d01d1c13 --- /dev/null +++ b/internal/platform/db/migrations/20260923110214_sequence_step_branches.up.sql @@ -0,0 +1,113 @@ +-- 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. + +-- 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; + +-- Reply conditions look for an inbound message on the enrollment's campaign and +-- contact; every existing 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. Not CONCURRENTLY: +-- golang-migrate runs a file in one transaction (see +-- 20260827185855_inbox_messages_mailbox_id). +CREATE INDEX idx_inbox_threads_campaign_contact + ON inbox_threads (campaign_id, contact_id) WHERE campaign_id IS NOT NULL; diff --git a/internal/platform/db/queries/enrollment.sql b/internal/platform/db/queries/enrollment.sql index 62857510..e07e9179 100644 --- a/internal/platform/db/queries/enrollment.sql +++ b/internal/platform/db/queries/enrollment.sql @@ -134,3 +134,46 @@ 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. +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 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..5ba8fcf3 --- /dev/null +++ b/internal/platform/db/queries/stepbranch.sql @@ -0,0 +1,89 @@ +-- 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. +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, updated_at = now() +WHERE sequence_step_branches.workspace_id = EXCLUDED.workspace_id + AND sequence_step_branches.campaign_id = EXCLUDED.campaign_id +RETURNING *; + +-- 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: 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: ReplyLabelKeyExists :one +-- Whether the workspace defines a reply label with this key. Save-time +-- validation only: a branch naming a label that does not exist could never match. +SELECT EXISTS (SELECT 1 FROM reply_labels WHERE workspace_id = $1 AND key = $2)::bool; + +-- 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 82). 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: a routed step whose row predates the enrollment's last +-- send 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..6352d08b 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,13 @@ 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. SELECT message_id, references_header FROM sends WHERE campaign_id = $1 AND contact_id = $2 AND status = 'sent' -ORDER BY step_order DESC +ORDER BY sent_at DESC NULLS LAST, step_order DESC LIMIT 1; diff --git a/internal/platform/seqgraph/seqgraph.go b/internal/platform/seqgraph/seqgraph.go new file mode 100644 index 00000000..63dfbee8 --- /dev/null +++ b/internal/platform/seqgraph/seqgraph.go @@ -0,0 +1,492 @@ +// 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" + 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/sequence/advance.go b/internal/worker/sequence/advance.go index 7cb75af2..cc752d1f 100644 --- a/internal/worker/sequence/advance.go +++ b/internal/worker/sequence/advance.go @@ -167,6 +167,22 @@ 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. + // result=deferred: a self-clearing wait, the same bucket as the + // campaign-limit and capacity defers below. Metric AFTER the enqueue, for + // their double-count reason. + if job.ConditionPending { + if err := enq.EnqueueAdvanceAt(ctx, p.EnrollmentID, p.WorkspaceID, job.RecheckAt); err != nil { + return err + } + mtx.SendFinalized(sendKind, "deferred") + return nil + } + // 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..84a501c4 --- /dev/null +++ b/internal/worker/sequence/branching_integration_test.go @@ -0,0 +1,460 @@ +//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) + } +} + +// reply stores an inbound reply the way the inbox poller does: a thread on the +// campaign + contact, and an inbound message classified replyClass. +func (f branchFixture) reply(t *testing.T, replyClass string) { + t.Helper() + ctx := context.Background() + var mailbox uuid.UUID + if err := f.pool.QueryRow(ctx, `SELECT mailbox_id FROM campaigns WHERE id = $1`, f.campaignID).Scan(&mailbox); err != nil { + t.Fatalf("mailbox: %v", err) + } + var thread uuid.UUID + if err := f.pool.QueryRow(ctx, ` + INSERT INTO inbox_threads (workspace_id, mailbox_id, campaign_id, contact_id, root_message_id) + VALUES ($1, $2, $3, $4, $5) RETURNING id`, + f.ws, mailbox, f.campaignID, f.contactID, "").Scan(&thread); err != nil { + t.Fatalf("thread: %v", err) + } + if _, err := f.pool.Exec(ctx, ` + INSERT INTO inbox_messages (thread_id, workspace_id, mailbox_id, direction, message_id, reply_class, occurred_at) + VALUES ($1, $2, $3, 'inbound', $4, $5, now())`, + thread, f.ws, mailbox, "", replyClass); err != nil { + t.Fatalf("message: %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 82). +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") +} + +// Replies span both legs: the step went out as a sends row, the answer is an +// inbox_messages row. An out-of-office is not a reply; a human one is, and it +// routes YES without waiting out the window. +func TestBranchRepliedCountsHumanRepliesOnly(t *testing.T) { + f, done := seedBranchCampaign(t) + defer done() + f.branch(t, f.steps[0], "replied", 3, &f.steps[2], &f.steps[1]) + + f.advance(t) + f.reply(t, "out_of_office") + f.due(t) + f.advance(t) + f.requireSent(t, "S1") + + f.reply(t, "neutral") + f.due(t) + f.advance(t) + f.requireSent(t, "S1", "S3") +} + +// A reply that does not stop the enrollment nudges one waiting on a reply +// condition to now, so the next sweep routes it; an automated reply must not +// (it is the kind that DEFERS an enrollment, and pulling it forward would undo +// the deferral). +func TestBranchReplyNudgesAwaitingEnrollment(t *testing.T) { + f, done := seedBranchCampaign(t) + defer done() + ctx := context.Background() + f.branch(t, f.steps[0], "replied", 3, &f.steps[2], nil) + + f.advance(t) + f.due(t) + f.advance(t) // parks until the next recheck + parked := f.enrollment(t).NextDueAt.Time + if !parked.After(time.Now()) { + t.Fatalf("precondition: enrollment should be parked, due %v", parked) + } + + if err := f.core.RecordReplyClass(ctx, f.eid, f.ws.String(), "out_of_office", "rules", 1); err != nil { + t.Fatal(err) + } + if got := f.enrollment(t).NextDueAt.Time; !got.Equal(parked) { + t.Fatalf("an automated reply moved the due time %v -> %v", parked, got) + } + + if err := f.core.RecordReplyClass(ctx, f.eid, f.ws.String(), "neutral", "rules", 1); err != nil { + t.Fatal(err) + } + // A minute of slack for the database container's clock against this one; + // the parked due time was an hour out. + if got := f.enrollment(t).NextDueAt.Time; got.After(time.Now().Add(time.Minute)) { + t.Fatalf("a human reply must pull the due time to now, still %v", got) + } +} + +// 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..926ae1b5 --- /dev/null +++ b/internal/worker/sequence/branchwait_test.go @@ -0,0 +1,49 @@ +package sequence + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/inroad/inroad/internal/coreapi" +) + +// 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") + } +} + +// 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/src/store/api.ts b/web/src/store/api.ts index c8509107..154c5cf0 100644 --- a/web/src/store/api.ts +++ b/web/src/store/api.ts @@ -994,6 +994,31 @@ const injectedRtkApi = api.injectEndpoints({ body: queryArg.reorderStepsRequest, }), }), + getCampaignGraph: build.query< + GetCampaignGraphApiResponse, + GetCampaignGraphApiArg + >({ + query: (queryArg) => ({ url: `/campaigns/${queryArg.id}/graph` }), + }), + setStepBranch: build.mutation< + SetStepBranchApiResponse, + SetStepBranchApiArg + >({ + query: (queryArg) => ({ + url: `/campaigns/${queryArg.id}/steps/${queryArg.stepId}/branch`, + method: "PUT", + body: queryArg.stepBranchRequest, + }), + }), + deleteStepBranch: build.mutation< + DeleteStepBranchApiResponse, + DeleteStepBranchApiArg + >({ + query: (queryArg) => ({ + url: `/campaigns/${queryArg.id}/steps/${queryArg.stepId}/branch`, + method: "DELETE", + }), + }), launchCampaign: build.mutation< LaunchCampaignApiResponse, LaunchCampaignApiArg @@ -2506,6 +2531,23 @@ export type ReorderStepsApiArg = { id: string; reorderStepsRequest: ReorderStepsRequest; }; +export type GetCampaignGraphApiResponse = + /** status 200 The graph */ CampaignGraph; +export type GetCampaignGraphApiArg = { + id: string; +}; +export type SetStepBranchApiResponse = + /** status 200 The saved branch */ StepBranch; +export type SetStepBranchApiArg = { + id: string; + stepId: string; + stepBranchRequest: StepBranchRequest; +}; +export type DeleteStepBranchApiResponse = unknown; +export type DeleteStepBranchApiArg = { + id: string; + stepId: string; +}; export type LaunchCampaignApiResponse = /** status 200 Enrollment + queue counts */ { queued?: number; @@ -4375,6 +4417,20 @@ export type StepRequest = { body_text?: string; body_html?: string; }; +export type BranchValidationError = { + /** Human-readable message */ + error: string; + code: + | "invalid_condition" + | "invalid_within_days" + | "invalid_reply_label" + | "no_exit_not_allowed" + | "unknown_step" + | "unknown_target" + | "cycle"; + /** code cycle only: the steps on the loop, in path order */ + step_ids?: string[]; +}; export type StepVariant = { id: string; step_id: string; @@ -4401,6 +4457,47 @@ export type ReorderStepsRequest = { /** the FULL ordered list of the campaign's step ids, in the desired order */ step_ids: string[]; }; +export type StepBranchCondition = + "always" | "opened" | "clicked" | "replied" | "not_opened" | "not_replied"; +export type StepBranch = { + /** The step this branch routes out of */ + step_id: string; + condition: StepBranchCondition; + /** Evaluation window in days after the step's send; null exactly when condition is always */ + within_days: number | null; + /** replied / not_replied only: count only replies classified with this reply label key */ + reply_label_key: string | null; + /** Where a true condition (or always) goes; null ends the path */ + yes_step_id: string | null; + /** Where a false condition goes; null ends the path; always null for always */ + no_step_id: string | null; + updated_at: string; +}; +export type CampaignGraphNode = { + step_id: string; + step_order: number; + /** 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. */ + default_next_step_id: string | null; + /** The step's branch, or null for linear fall-through to default_next_step_id. */ + branch: StepBranch | null; +}; +export type CampaignGraph = { + campaign_id: string; + /** The first step (lowest step_order) every enrollment starts at; null for a campaign with no steps */ + entry_step_id: string | null; + nodes: CampaignGraphNode[]; +}; +export type StepBranchRequest = { + condition: StepBranchCondition; + /** Required for every condition except always; must be absent or null for always */ + within_days?: number | null; + /** Optional, replied / not_replied only; must name a reply label key in the workspace. Empty string is treated as null. */ + reply_label_key?: string | null; + /** A step of the same campaign */ + yes_step_id?: string | null; + /** As yes_step_id; must be null or absent for always */ + no_step_id?: string | null; +}; export type CampaignPreflightCheck = { /** `personalization_tokens` FAILS (does not warn) when a step contains a `{{...}}` placeholder nothing will substitute, which is harsher than the neighbouring content checks on purpose: an empty body is visible the moment an operator looks at it, whereas a bad token produces an email that looks fine in the editor and arrives reading "Hi {{firstname}}" or "Hi ,". A token nothing resolves is always a typo or a since-archived field, never an intent. */ id: @@ -5351,6 +5448,9 @@ export const { useDeleteStepVariantMutation, useSetStepBaseWeightMutation, useReorderStepsMutation, + useGetCampaignGraphQuery, + useSetStepBranchMutation, + useDeleteStepBranchMutation, useLaunchCampaignMutation, useGetCampaignPreflightQuery, useTestSendCampaignMutation, From dce22366394d78bd5d77c4b5d7c2e6a409209c45 Mon Sep 17 00:00:00 2001 From: Ahmustufa Date: Wed, 23 Sep 2026 19:40:23 +0500 Subject: [PATCH 2/6] fix(api): quote the yes_step_id description so the contract lints Co-Authored-By: Claude Opus 5.5 (1M context) --- api/openapi.yaml | 2 +- web/src/store/api.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 7c9ac8fc..f5c83026 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -5169,7 +5169,7 @@ components: 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. 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 } + 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 } CampaignGraphNode: type: object diff --git a/web/src/store/api.ts b/web/src/store/api.ts index 154c5cf0..ef43d8d1 100644 --- a/web/src/store/api.ts +++ b/web/src/store/api.ts @@ -4493,7 +4493,7 @@ export type StepBranchRequest = { within_days?: number | null; /** Optional, replied / not_replied only; must name a reply label key in the workspace. Empty string is treated as null. */ reply_label_key?: string | null; - /** A step of the same campaign */ + /** A step of the same campaign, not this step; null or absent ends the path */ yes_step_id?: string | null; /** As yes_step_id; must be null or absent for always */ no_step_id?: string | null; From dc15a7e5c3331061363a7ea9c5abe8d2584c2fc3 Mon Sep 17 00:00:00 2001 From: Ahmustufa Date: Wed, 23 Sep 2026 20:06:14 +0500 Subject: [PATCH 3/6] fix(sequences): branching review and security fixes Reply branches (product decision: stop-on-reply stays, so labels win): - A reply branch on a label that stops the sequence is refused at save (400 reply_label_stops_sequence). The API says every default human label stops, so a default-label "replied" branch never fires. The replacement tests go through the real poll -> classify -> dispatch path. - A reply nudge only pulls forward a due time that a condition wait set, never an out-of-office deferral. Routing: - The loop backstop compares against the current step's own send row, not enrollment.last_sent_at, which a recover-forward re-stamps. - Paused and done campaigns neither finish nor park enrollments; the status gate now runs before routing. - opened/clicked/not_opened are refused without tracking or an HTML body (400 tracking_required). - Condition waits no longer count as "deferred" sends. - Only a real miss is a 404. - LatestSentForContact is workspace-pinned. - New test: a routed send still honours suppression. Migration: the inbox_threads index is its own single-statement CONCURRENTLY migration (asserted indisvalid on a scratch DB), so the branching migration no longer blocks the send path. Docs: Postgres 15+ minimum; mid-flight edit rules corrected; invariant 82 notes that reply evidence is thread-scoped. Co-Authored-By: Claude Opus 5.5 (1M context) --- api/openapi.yaml | 53 +++-- docs/src/content/docs/deploy/aws-terraform.md | 2 +- .../src/content/docs/deploy/docker-compose.md | 2 +- .../content/docs/deploy/kubernetes-helm.md | 4 + docs/src/content/docs/security.md | 17 +- internal/app/sequencestep/branch.go | 144 +++++++++-- .../sequencestep/branch_integration_test.go | 54 ++++- internal/app/sequencestep/branch_test.go | 111 ++++++++- internal/app/sequencestep/branchstore.go | 25 +- internal/app/sequencestep/service.go | 10 +- internal/coreapi/coreapi.go | 10 +- internal/coreapi/inprocess/branchroute.go | 62 +++-- internal/coreapi/inprocess/stepsendjob.go | 50 ++-- internal/coreapi/remote/jobs_test.go | 30 +++ .../db/branchmigrations_integration_test.go | 89 +++++++ internal/platform/db/gen/enrollment.sql.go | 7 + internal/platform/db/gen/stepbranch.sql.go | 46 +++- internal/platform/db/gen/stepsend.sql.go | 13 +- ...0923110214_sequence_step_branches.down.sql | 2 - ...260923110214_sequence_step_branches.up.sql | 13 +- ...ox_threads_campaign_contact_index.down.sql | 2 + ...nbox_threads_campaign_contact_index.up.sql | 18 ++ internal/platform/db/queries/enrollment.sql | 7 + internal/platform/db/queries/stepbranch.sql | 21 +- internal/platform/db/queries/stepsend.sql | 6 +- internal/platform/db/tenancyqueries_test.go | 3 +- internal/platform/seqgraph/seqgraph.go | 14 +- .../inbox/branching_integration_test.go | 224 ++++++++++++++++++ internal/worker/sequence/advance.go | 14 +- .../sequence/branching_integration_test.go | 118 ++++----- internal/worker/sequence/branchwait_test.go | 17 ++ web/src/store/api.ts | 6 +- 32 files changed, 991 insertions(+), 203 deletions(-) create mode 100644 internal/platform/db/branchmigrations_integration_test.go create mode 100644 internal/platform/db/migrations/20260923144758_inbox_threads_campaign_contact_index.down.sql create mode 100644 internal/platform/db/migrations/20260923144758_inbox_threads_campaign_contact_index.up.sql create mode 100644 internal/worker/inbox/branching_integration_test.go diff --git a/api/openapi.yaml b/api/openapi.yaml index f5c83026..1d5bbb92 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2245,7 +2245,7 @@ paths: '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) + 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: @@ -2268,7 +2268,7 @@ paths: '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) } + '403': { description: "Insufficient scope (campaigns:read)" } '404': { description: Campaign not found } /campaigns/{id}/steps/{stepId}/branch: parameters: @@ -2291,14 +2291,25 @@ paths: never affected. - Conditions are evaluated against THIS step's send: opened / clicked - count HUMAN tracking events only (machine prefetches are ignored); - replied / not_replied count inbound replies from the contact on this - campaign, excluding automated ones (out-of-office, auto-reply) unless - reply_label_key names one explicitly. A reply whose label stops the - enrollment (the default for every human label) still stops it - a - branch never overrides a reply label's automation - so a replied branch - acts on replies whose label has stops_enrollment=false. + 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 @@ -2311,13 +2322,13 @@ paths: responses: '200': { description: The saved branch, content: { application/json: { schema: { $ref: '#/components/schemas/StepBranch' } } } } '400': - description: "Malformed branch (codes invalid_condition, invalid_within_days, invalid_reply_label, no_exit_not_allowed; cycle for an exit to the step itself). Invalid json or path ids return a plain {error} body." + 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) } + '403': { description: "Insufficient scope (campaigns:write)" } '404': { description: Campaign or step not found } '422': - description: The graph refuses the edit (codes cycle, unknown_target) + description: "The graph refuses the edit (codes cycle, unknown_target)" content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } } delete: operationId: deleteStepBranch @@ -2332,7 +2343,7 @@ paths: responses: '204': { description: Removed (or there was none) } '401': { description: Unauthorized } - '403': { description: Insufficient scope (campaigns:write) } + '403': { description: "Insufficient scope (campaigns:write)" } '404': { description: Campaign or step not found } '422': description: The restored fall-through would close a loop (code cycle) @@ -5158,7 +5169,7 @@ components: 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" } + 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 } @@ -5167,10 +5178,10 @@ components: 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. Empty string is treated as null." } + 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 } + no_step_id: { type: string, format: uuid, nullable: true, description: "As yes_step_id; must be null or absent for always" } CampaignGraphNode: type: object required: [step_id, step_order, default_next_step_id, branch] @@ -5182,13 +5193,13 @@ components: 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. + 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 } + 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 @@ -5197,7 +5208,7 @@ components: error: { type: string, description: Human-readable message } code: type: string - enum: [invalid_condition, invalid_within_days, invalid_reply_label, no_exit_not_allowed, unknown_step, unknown_target, cycle] + enum: [invalid_condition, invalid_within_days, invalid_reply_label, reply_label_stops_sequence, tracking_required, no_exit_not_allowed, unknown_step, unknown_target, cycle] step_ids: type: array items: { type: string, format: uuid } 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 bf69a2b8..4ef54093 100644 --- a/docs/src/content/docs/security.md +++ b/docs/src/content/docs/security.md @@ -2284,6 +2284,17 @@ write history that never happened. `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`, @@ -2296,7 +2307,11 @@ write history that never happened. **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. + 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) - Datacenter/cloud IP ranges as a refreshed table (AWS/GCP/Azure publish diff --git a/internal/app/sequencestep/branch.go b/internal/app/sequencestep/branch.go index e32e0b11..8683fc97 100644 --- a/internal/app/sequencestep/branch.go +++ b/internal/app/sequencestep/branch.go @@ -2,10 +2,12 @@ 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" @@ -18,10 +20,24 @@ type Graph struct { 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.checker.CampaignStatus(ctx, ws, campaignID); err != nil { - return Graph{}, ErrCampaignNotFound + if err := s.requireCampaign(ctx, ws, campaignID); err != nil { + return Graph{}, err } steps, err := s.store.List(ctx, ws, campaignID) if err != nil { @@ -42,16 +58,17 @@ func (s *Service) Graph(ctx context.Context, ws, campaignID uuid.UUID) (Graph, e // 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 and label 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. +// 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. func (s *Service) SetBranch(ctx context.Context, ws, campaignID uuid.UUID, in BranchInput) (gen.SequenceStepBranch, error) { - if _, err := s.checker.CampaignStatus(ctx, ws, campaignID); err != nil { - return gen.SequenceStepBranch{}, ErrCampaignNotFound + if err := s.requireCampaign(ctx, ws, campaignID); err != nil { + return gen.SequenceStepBranch{}, err } - if err := s.assertStepInCampaign(ctx, ws, campaignID, in.StepID); err != nil { + 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 @@ -62,20 +79,15 @@ func (s *Service) SetBranch(ctx context.Context, ws, campaignID uuid.UUID, in Br Code: seqgraph.CodeInvalidWithinDays, Msg: "within_days is not allowed on an 'always' branch", } } - if err := branchModel(in).ValidateShape(); err != nil { + model := branchModel(in) + if err := model.ValidateShape(); err != nil { return gen.SequenceStepBranch{}, err } - if in.ReplyLabelKey != nil { - ok, err := s.branches.ReplyLabelExists(ctx, ws, *in.ReplyLabelKey) - if err != nil { - return gen.SequenceStepBranch{}, fmt.Errorf("check reply label: %w", err) - } - if !ok { - return gen.SequenceStepBranch{}, &seqgraph.ShapeError{ - Code: seqgraph.CodeLabelNotAllowed, - Msg: fmt.Sprintf("reply label %q does not exist in this workspace", *in.ReplyLabelKey), - } - } + 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 @@ -93,14 +105,96 @@ func (s *Service) SetBranch(ctx context.Context, ws, campaignID uuid.UUID, in Br 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 82). 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. Allowed live, for the reason SetBranch is; refused // with a CycleError when the restored fall-through would close a loop. func (s *Service) DeleteBranch(ctx context.Context, ws, campaignID, stepID uuid.UUID) error { - if _, err := s.checker.CampaignStatus(ctx, ws, campaignID); err != nil { - return ErrCampaignNotFound + if err := s.requireCampaign(ctx, ws, campaignID); err != nil { + return err } - if err := s.assertStepInCampaign(ctx, ws, campaignID, stepID); err != nil { + if _, err := s.stepInCampaign(ctx, ws, campaignID, stepID); err != nil { return err } return s.branches.DeleteBranch(ctx, ws, campaignID, stepID, checkGraph) diff --git a/internal/app/sequencestep/branch_integration_test.go b/internal/app/sequencestep/branch_integration_test.go index 1bb1a27a..3768a61d 100644 --- a/internal/app/sequencestep/branch_integration_test.go +++ b/internal/app/sequencestep/branch_integration_test.go @@ -47,6 +47,11 @@ func newBranchIT(t *testing.T, label string) (branchIT, func()) { } 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 } @@ -74,7 +79,14 @@ func TestBranchSaveAndReadBack(t *testing.T) { defer done() ctx := context.Background() three := int32(3) - label := "positive" // seeded for every workspace by migration 000047 + // 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], @@ -106,6 +118,46 @@ func TestBranchSaveAndReadBack(t *testing.T) { } } +// 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() diff --git a/internal/app/sequencestep/branch_test.go b/internal/app/sequencestep/branch_test.go index 01b528d7..119c71e5 100644 --- a/internal/app/sequencestep/branch_test.go +++ b/internal/app/sequencestep/branch_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/inroad/inroad/internal/platform/db/gen" @@ -18,16 +19,25 @@ import ( type fakeBranchStore struct { steps []gen.SequenceStep branches map[uuid.UUID]gen.SequenceStepBranch - labels map[string]bool - upserts int + // 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 } func (f *fakeBranchStore) ListBranches(context.Context, uuid.UUID, uuid.UUID) ([]gen.SequenceStepBranch, error) { return f.list(), nil } -func (f *fakeBranchStore) ReplyLabelExists(_ context.Context, _ uuid.UUID, key string) (bool, error) { - return f.labels[key], 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) { @@ -78,6 +88,8 @@ func (f *fakeBranchStore) list() []gen.SequenceStepBranch { type branchFixture struct { svc *Service branches *fakeBranchStore + variants *fakeVariantStore + steps *stepsByID campaign uuid.UUID ws uuid.UUID step []uuid.UUID @@ -91,13 +103,16 @@ func newBranchFixture(t *testing.T, status string) branchFixture { for i := range 3 { id := uuid.New() ids = append(ids, id) - steps = append(steps, gen.SequenceStep{ID: id, CampaignID: campaign, StepOrder: int32(i + 1)}) + steps = append(steps, gen.SequenceStep{ID: id, CampaignID: campaign, StepOrder: int32(i + 1), BodyHtml: "

hi

"}) } - bs := &fakeBranchStore{steps: steps, labels: map[string]bool{"positive": true}} + // "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}, &fakeVariantStore{}, bs), - branches: bs, campaign: campaign, ws: uuid.New(), step: ids, + svc: NewService(store, fakeChecker{status: status}, vs, bs), + branches: bs, variants: vs, steps: store, campaign: campaign, ws: uuid.New(), step: ids, } } @@ -139,13 +154,86 @@ func TestSetBranchHappyPathOnRunningCampaign(t *testing.T) { func TestSetBranchRejectsMissingCampaign(t *testing.T) { f := newBranchFixture(t, "draft") - svc := NewService(&fakeStore{}, fakeChecker{err: errors.New("no rows")}, &fakeVariantStore{}, f.branches) + 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]) + 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) { @@ -209,6 +297,9 @@ func TestSetBranchShapeErrors(t *testing.T) { "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}, @@ -289,7 +380,7 @@ func TestGraphReturnsStepsAndBranches(t *testing.T) { } func TestGraphRejectsMissingCampaign(t *testing.T) { - svc := NewService(&fakeStore{}, fakeChecker{err: errors.New("no rows")}, &fakeVariantStore{}, &fakeBranchStore{}) + 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) } diff --git a/internal/app/sequencestep/branchstore.go b/internal/app/sequencestep/branchstore.go index 42d291f2..4d5a606c 100644 --- a/internal/app/sequencestep/branchstore.go +++ b/internal/app/sequencestep/branchstore.go @@ -41,7 +41,11 @@ type BranchInput struct { // graph rules testable without a database. type BranchStore interface { ListBranches(ctx context.Context, ws, campaignID uuid.UUID) ([]gen.SequenceStepBranch, error) - ReplyLabelExists(ctx context.Context, ws uuid.UUID, key string) (bool, 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. UpsertBranch(ctx context.Context, ws uuid.UUID, in BranchInput, check GraphCheck) (gen.SequenceStepBranch, error) @@ -81,8 +85,23 @@ func (s *PgBranchStore) ListBranches(ctx context.Context, ws, campaignID uuid.UU return s.q.ListBranchesByCampaign(ctx, gen.ListBranchesByCampaignParams{CampaignID: campaignID, WorkspaceID: ws}) } -func (s *PgBranchStore) ReplyLabelExists(ctx context.Context, ws uuid.UUID, key string) (bool, error) { - return s.q.ReplyLabelKeyExists(ctx, gen.ReplyLabelKeyExistsParams{WorkspaceID: ws, Key: key}) +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 } func (s *PgBranchStore) UpsertBranch(ctx context.Context, ws uuid.UUID, in BranchInput, check GraphCheck) (gen.SequenceStepBranch, error) { diff --git a/internal/app/sequencestep/service.go b/internal/app/sequencestep/service.go index 105549b3..542a754f 100644 --- a/internal/app/sequencestep/service.go +++ b/internal/app/sequencestep/service.go @@ -163,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/coreapi/coreapi.go b/internal/coreapi/coreapi.go index d6bb0bb9..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 diff --git a/internal/coreapi/inprocess/branchroute.go b/internal/coreapi/inprocess/branchroute.go index a8f30dca..b35b9b25 100644 --- a/internal/coreapi/inprocess/branchroute.go +++ b/internal/coreapi/inprocess/branchroute.go @@ -48,9 +48,21 @@ import ( // 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 (at most -// conditionRecheckInterval away). A lengthened window keeps waiting; a -// shortened one decides at the next check. +// 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 @@ -266,7 +278,7 @@ func (c client) routeGraph(ctx context.Context, ws uuid.UUID, enrollmentID strin return graphRouteResult{routeDecision: d}, nil } if b.CurrentStep > 0 && g.HasBranches() { - revisit, err := c.revisits(ctx, ws, b, d.target) + revisit, err := c.revisits(ctx, ws, b, b.CurrentStep, d.target) if err != nil { return graphRouteResult{}, err } @@ -292,23 +304,43 @@ func (c client) routeGraph(ctx context.Context, ws uuid.UUID, enrollmentID strin // 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 is whether the row predates the cursor step's own -// send: a recover-forward row is created after it, a loop's row long before. -func (c client) revisits(ctx context.Context, ws uuid.UUID, b gen.GetStepEnrollmentBundleRow, target seqgraph.Step) (bool, error) { +// 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(target.Order)), WorkspaceID: ws, + ID: deriveStepSendID(b.CampaignID, b.ContactID, int(order)), WorkspaceID: ws, }) if errors.Is(err, pgx.ErrNoRows) { - return false, nil + return time.Time{}, false, nil } if err != nil { - return false, fmt.Errorf("step send lookup: %w", err) + return time.Time{}, false, fmt.Errorf("step send lookup: %w", err) } - // No tolerance, unlike the due checks: both instants are the DATABASE's - // now() (sends.created_at's default and the cursor advance's last_sent_at), - // so there is no clock skew to absorb — and a tolerance would blind this to - // exactly the loop that matters most, zero-delay steps a few seconds apart. - return created.Time.Before(b.LastSentAt.Time), nil + return created.Time, true, nil } // firstEvidence reads the earliest qualifying event for one condition. diff --git a/internal/coreapi/inprocess/stepsendjob.go b/internal/coreapi/inprocess/stepsendjob.go index c0332ff2..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) @@ -243,6 +250,14 @@ func (c client) localStepSendJob(ctx context.Context, enrollmentID, workspaceID 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 @@ -281,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 @@ -390,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 @@ -469,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..85b7d973 --- /dev/null +++ b/internal/platform/db/branchmigrations_integration_test.go @@ -0,0 +1,89 @@ +//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 +) + +// 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) + } + + // 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 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) + } +} diff --git a/internal/platform/db/gen/enrollment.sql.go b/internal/platform/db/gen/enrollment.sql.go index 4d3ab4bd..accd88dd 100644 --- a/internal/platform/db/gen/enrollment.sql.go +++ b/internal/platform/db/gen/enrollment.sql.go @@ -402,6 +402,7 @@ 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), @@ -427,6 +428,12 @@ type NudgeEnrollmentAwaitingReplyParams struct { // 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 diff --git a/internal/platform/db/gen/stepbranch.sql.go b/internal/platform/db/gen/stepbranch.sql.go index 6327d453..03eabf28 100644 --- a/internal/platform/db/gen/stepbranch.sql.go +++ b/internal/platform/db/gen/stepbranch.sql.go @@ -12,6 +12,24 @@ import ( "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 @@ -178,22 +196,25 @@ func (q *Queries) LockCampaignGraph(ctx context.Context, arg LockCampaignGraphPa return id, err } -const replyLabelKeyExists = `-- name: ReplyLabelKeyExists :one -SELECT EXISTS (SELECT 1 FROM reply_labels WHERE workspace_id = $1 AND key = $2)::bool +const replyLabelStopsEnrollment = `-- name: ReplyLabelStopsEnrollment :one +SELECT stops_enrollment FROM reply_labels WHERE workspace_id = $1 AND key = $2 ` -type ReplyLabelKeyExistsParams struct { +type ReplyLabelStopsEnrollmentParams struct { WorkspaceID uuid.UUID `json:"workspace_id"` Key string `json:"key"` } -// Whether the workspace defines a reply label with this key. Save-time -// validation only: a branch naming a label that does not exist could never match. -func (q *Queries) ReplyLabelKeyExists(ctx context.Context, arg ReplyLabelKeyExistsParams) (bool, error) { - row := q.db.QueryRow(ctx, replyLabelKeyExists, arg.WorkspaceID, arg.Key) - var column_1 bool - err := row.Scan(&column_1) - return column_1, err +// 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 @@ -206,8 +227,9 @@ type StepSendCreatedAtParams struct { } // When a (deterministically-id'd) step send row was first created. The send -// path's cycle backstop: a routed step whose row predates the enrollment's last -// send was visited EARLIER on this path, i.e. the graph now loops. Workspace-pinned. +// 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 diff --git a/internal/platform/db/gen/stepsend.sql.go b/internal/platform/db/gen/stepsend.sql.go index 5bcce93f..28da9e1f 100644 --- a/internal/platform/db/gen/stepsend.sql.go +++ b/internal/platform/db/gen/stepsend.sql.go @@ -226,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' +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 { @@ -249,8 +250,12 @@ type LatestSentForContactRow struct { // 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/migrations/20260923110214_sequence_step_branches.down.sql b/internal/platform/db/migrations/20260923110214_sequence_step_branches.down.sql index a080d200..bb4790a2 100644 --- a/internal/platform/db/migrations/20260923110214_sequence_step_branches.down.sql +++ b/internal/platform/db/migrations/20260923110214_sequence_step_branches.down.sql @@ -1,5 +1,3 @@ -DROP INDEX IF EXISTS idx_inbox_threads_campaign_contact; - ALTER TABLE sequence_enrollments DROP COLUMN awaiting_condition_step; DROP TABLE sequence_step_branches; diff --git a/internal/platform/db/migrations/20260923110214_sequence_step_branches.up.sql b/internal/platform/db/migrations/20260923110214_sequence_step_branches.up.sql index d01d1c13..06f4bfc1 100644 --- a/internal/platform/db/migrations/20260923110214_sequence_step_branches.up.sql +++ b/internal/platform/db/migrations/20260923110214_sequence_step_branches.up.sql @@ -27,6 +27,10 @@ -- 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 @@ -102,12 +106,3 @@ CREATE INDEX idx_sequence_step_branches_no ON sequence_step_branches (no_step_id -- 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; - --- Reply conditions look for an inbound message on the enrollment's campaign and --- contact; every existing 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. Not CONCURRENTLY: --- golang-migrate runs a file in one transaction (see --- 20260827185855_inbox_messages_mailbox_id). -CREATE INDEX 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/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/queries/enrollment.sql b/internal/platform/db/queries/enrollment.sql index e07e9179..0360dca9 100644 --- a/internal/platform/db/queries/enrollment.sql +++ b/internal/platform/db/queries/enrollment.sql @@ -163,10 +163,17 @@ WHERE id = $1 AND workspace_id = $2 AND status = 'active'; -- 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), diff --git a/internal/platform/db/queries/stepbranch.sql b/internal/platform/db/queries/stepbranch.sql index 5ba8fcf3..7d014e8f 100644 --- a/internal/platform/db/queries/stepbranch.sql +++ b/internal/platform/db/queries/stepbranch.sql @@ -40,10 +40,18 @@ WHERE step_id = $1 AND campaign_id = $2 AND workspace_id = $3; -- 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: ReplyLabelKeyExists :one --- Whether the workspace defines a reply label with this key. Save-time --- validation only: a branch naming a label that does not exist could never match. -SELECT EXISTS (SELECT 1 FROM reply_labels WHERE workspace_id = $1 AND key = $2)::bool; +-- 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 @@ -84,6 +92,7 @@ LIMIT 1; -- name: StepSendCreatedAt :one -- When a (deterministically-id'd) step send row was first created. The send --- path's cycle backstop: a routed step whose row predates the enrollment's last --- send was visited EARLIER on this path, i.e. the graph now loops. Workspace-pinned. +-- 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 6352d08b..0d9028b5 100644 --- a/internal/platform/db/queries/stepsend.sql +++ b/internal/platform/db/queries/stepsend.sql @@ -103,7 +103,11 @@ SELECT COALESCE(sqlc.narg(not_due_until)::timestamptz > now(), false)::bool AS n -- 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' +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 index 63dfbee8..2f36b106 100644 --- a/internal/platform/seqgraph/seqgraph.go +++ b/internal/platform/seqgraph/seqgraph.go @@ -144,10 +144,16 @@ const ( CodeInvalidCondition = "invalid_condition" CodeInvalidWithinDays = "invalid_within_days" CodeLabelNotAllowed = "invalid_reply_label" - CodeNoExitNotAllowed = "no_exit_not_allowed" - CodeUnknownStep = "unknown_step" - CodeUnknownTarget = "unknown_target" - CodeCycle = "cycle" + // 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 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 cc752d1f..ca8e120e 100644 --- a/internal/worker/sequence/advance.go +++ b/internal/worker/sequence/advance.go @@ -172,15 +172,13 @@ func AdvanceHandler(core coreapi.Client, sender Sender, enq Enqueuer, publicURL // 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. - // result=deferred: a self-clearing wait, the same bucket as the - // campaign-limit and capacity defers below. Metric AFTER the enqueue, for - // their double-count reason. + // + // 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 { - if err := enq.EnqueueAdvanceAt(ctx, p.EnrollmentID, p.WorkspaceID, job.RecheckAt); err != nil { - return err - } - mtx.SendFinalized(sendKind, "deferred") - return nil + return enq.EnqueueAdvanceAt(ctx, p.EnrollmentID, p.WorkspaceID, job.RecheckAt) } // Enrollment no longer active (stopped/completed) or no next step. diff --git a/internal/worker/sequence/branching_integration_test.go b/internal/worker/sequence/branching_integration_test.go index 84a501c4..af9a63ad 100644 --- a/internal/worker/sequence/branching_integration_test.go +++ b/internal/worker/sequence/branching_integration_test.go @@ -122,30 +122,6 @@ func (f branchFixture) track(t *testing.T, order int, kind string, machine bool) } } -// reply stores an inbound reply the way the inbox poller does: a thread on the -// campaign + contact, and an inbound message classified replyClass. -func (f branchFixture) reply(t *testing.T, replyClass string) { - t.Helper() - ctx := context.Background() - var mailbox uuid.UUID - if err := f.pool.QueryRow(ctx, `SELECT mailbox_id FROM campaigns WHERE id = $1`, f.campaignID).Scan(&mailbox); err != nil { - t.Fatalf("mailbox: %v", err) - } - var thread uuid.UUID - if err := f.pool.QueryRow(ctx, ` - INSERT INTO inbox_threads (workspace_id, mailbox_id, campaign_id, contact_id, root_message_id) - VALUES ($1, $2, $3, $4, $5) RETURNING id`, - f.ws, mailbox, f.campaignID, f.contactID, "").Scan(&thread); err != nil { - t.Fatalf("thread: %v", err) - } - if _, err := f.pool.Exec(ctx, ` - INSERT INTO inbox_messages (thread_id, workspace_id, mailbox_id, direction, message_id, reply_class, occurred_at) - VALUES ($1, $2, $3, 'inbound', $4, $5, now())`, - thread, f.ws, mailbox, "", replyClass); err != nil { - t.Fatalf("message: %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}) @@ -226,58 +202,90 @@ func TestBranchMachineOpenDoesNotCount(t *testing.T) { f.requireSent(t, "S1", "S2") } -// Replies span both legs: the step went out as a sends row, the answer is an -// inbox_messages row. An out-of-office is not a reply; a human one is, and it -// routes YES without waiting out the window. -func TestBranchRepliedCountsHumanRepliesOnly(t *testing.T) { +// 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], "replied", 3, &f.steps[2], &f.steps[1]) + f.branch(t, f.steps[0], "opened", 2, &f.steps[2], &f.steps[1]) f.advance(t) - f.reply(t, "out_of_office") - f.due(t) - f.advance(t) - f.requireSent(t, "S1") + 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.reply(t, "neutral") f.due(t) f.advance(t) - f.requireSent(t, "S1", "S3") + 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 reply that does not stop the enrollment nudges one waiting on a reply -// condition to now, so the next sweep routes it; an automated reply must not -// (it is the kind that DEFERS an enrollment, and pulling it forward would undo -// the deferral). -func TestBranchReplyNudgesAwaitingEnrollment(t *testing.T) { +// 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], "replied", 3, &f.steps[2], nil) + f.branch(t, f.steps[0], "not_opened", 1, nil, &f.steps[1]) // silence -> end f.advance(t) - f.due(t) - f.advance(t) // parks until the next recheck - parked := f.enrollment(t).NextDueAt.Time - if !parked.After(time.Now()) { - t.Fatalf("precondition: enrollment should be parked, due %v", parked) + 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) } - - if err := f.core.RecordReplyClass(ctx, f.eid, f.ws.String(), "out_of_office", "rules", 1); err != nil { + job, err := f.core.GetStepSendJob(ctx, f.eid, f.ws.String()) + if err != nil { t.Fatal(err) } - if got := f.enrollment(t).NextDueAt.Time; !got.Equal(parked) { - t.Fatalf("an automated reply moved the due time %v -> %v", parked, got) + 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.core.RecordReplyClass(ctx, f.eid, f.ws.String(), "neutral", "rules", 1); err != nil { + if _, err := f.pool.Exec(ctx, `UPDATE campaigns SET status = 'running' WHERE id = $1`, f.campaignID); err != nil { t.Fatal(err) } - // A minute of slack for the database container's clock against this one; - // the parked due time was an hour out. - if got := f.enrollment(t).NextDueAt.Time; got.After(time.Now().Add(time.Minute)) { - t.Fatalf("a human reply must pull the due time to now, still %v", got) + 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) } } diff --git a/internal/worker/sequence/branchwait_test.go b/internal/worker/sequence/branchwait_test.go index 926ae1b5..39db9bc4 100644 --- a/internal/worker/sequence/branchwait_test.go +++ b/internal/worker/sequence/branchwait_test.go @@ -7,6 +7,7 @@ import ( "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 @@ -28,6 +29,22 @@ func TestAdvanceConditionPendingSchedulesRecheck(t *testing.T) { } } +// 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. diff --git a/web/src/store/api.ts b/web/src/store/api.ts index ef43d8d1..2265288a 100644 --- a/web/src/store/api.ts +++ b/web/src/store/api.ts @@ -4424,6 +4424,8 @@ export type BranchValidationError = { | "invalid_condition" | "invalid_within_days" | "invalid_reply_label" + | "reply_label_stops_sequence" + | "tracking_required" | "no_exit_not_allowed" | "unknown_step" | "unknown_target" @@ -4465,7 +4467,7 @@ export type StepBranch = { condition: StepBranchCondition; /** Evaluation window in days after the step's send; null exactly when condition is always */ within_days: number | null; - /** replied / not_replied only: count only replies classified with this reply label key */ + /** replied / not_replied only: count only replies classified with this reply label key (always a label that does not stop the sequence) */ reply_label_key: string | null; /** Where a true condition (or always) goes; null ends the path */ yes_step_id: string | null; @@ -4491,7 +4493,7 @@ export type StepBranchRequest = { condition: StepBranchCondition; /** Required for every condition except always; must be absent or null for always */ within_days?: number | null; - /** Optional, replied / not_replied only; must name a reply label key in the workspace. Empty string is treated as null. */ + /** 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. */ reply_label_key?: string | null; /** A step of the same campaign, not this step; null or absent ends the path */ yes_step_id?: string | null; From 706d492c905bc717fc6f1934321de6ed49b8afd3 Mon Sep 17 00:00:00 2001 From: Ahmustufa Date: Thu, 24 Sep 2026 01:21:03 +0500 Subject: [PATCH 4/6] docs(sequences): cite the branching invariant as 84 after the rebase main now has the audit log at 82 and inbox search at 83 (#252). Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/app/sequencestep/branch.go | 2 +- internal/coreapi/inprocess/branchroute.go | 2 +- internal/platform/db/gen/stepbranch.sql.go | 2 +- internal/platform/db/queries/stepbranch.sql | 2 +- internal/worker/sequence/branching_integration_test.go | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/app/sequencestep/branch.go b/internal/app/sequencestep/branch.go index 8683fc97..21bd4908 100644 --- a/internal/app/sequencestep/branch.go +++ b/internal/app/sequencestep/branch.go @@ -108,7 +108,7 @@ func (s *Service) SetBranch(ctx context.Context, ws, campaignID uuid.UUID, in Br // 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 82). A label that stops the +// 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 diff --git a/internal/coreapi/inprocess/branchroute.go b/internal/coreapi/inprocess/branchroute.go index b35b9b25..d9da46ca 100644 --- a/internal/coreapi/inprocess/branchroute.go +++ b/internal/coreapi/inprocess/branchroute.go @@ -347,7 +347,7 @@ func (c client) stepSendCreatedAt(ctx context.Context, ws uuid.UUID, b gen.GetSt // // 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 82). Replies span both legs of the conversation: the step went +// 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, diff --git a/internal/platform/db/gen/stepbranch.sql.go b/internal/platform/db/gen/stepbranch.sql.go index 03eabf28..d81959f1 100644 --- a/internal/platform/db/gen/stepbranch.sql.go +++ b/internal/platform/db/gen/stepbranch.sql.go @@ -66,7 +66,7 @@ type FirstHumanTrackingEventAtParams struct { // 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 82). Served +// 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, diff --git a/internal/platform/db/queries/stepbranch.sql b/internal/platform/db/queries/stepbranch.sql index 7d014e8f..7898bd33 100644 --- a/internal/platform/db/queries/stepbranch.sql +++ b/internal/platform/db/queries/stepbranch.sql @@ -57,7 +57,7 @@ SELECT tracking_enabled FROM campaigns WHERE id = $1 AND workspace_id = $2; -- 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 82). Served +-- 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 diff --git a/internal/worker/sequence/branching_integration_test.go b/internal/worker/sequence/branching_integration_test.go index af9a63ad..c9c0a59a 100644 --- a/internal/worker/sequence/branching_integration_test.go +++ b/internal/worker/sequence/branching_integration_test.go @@ -173,7 +173,7 @@ func TestBranchOpenedRoutesYes(t *testing.T) { } // 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 82). +// and when the window closes it routes NO (docs/security.md invariant 84). func TestBranchMachineOpenDoesNotCount(t *testing.T) { f, done := seedBranchCampaign(t) defer done() From 67d22149051064e6fd9ebf63486b5d8abee7b05f Mon Sep 17 00:00:00 2001 From: Ahmustufa Date: Thu, 24 Sep 2026 01:21:03 +0500 Subject: [PATCH 5/6] fix(db): serialize migrations with a polling lock so CONCURRENTLY builds can't deadlock golang-migrate's pgx5 driver waits for its advisory lock inside a running statement, which holds a snapshot. CREATE INDEX CONCURRENTLY waits for every snapshot, so a second migrator waiting on the lock deadlocks the build, and the migration is left dirty. This reproduced 3 of 3 times with 6 concurrent migrators on a fresh DB, and it hits both CI (-p 4) and multi-replica deploys. Migrate, MigrateDown, MigrateTo and Version now take a separate session-level lock by polling pg_try_advisory_lock on a dedicated connection. A waiter is idle between tries and holds no snapshot. The wait is bounded (15 min, ErrMigrationLockTimeout), and unlock/close run on a detached context. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/platform/db/export_test.go | 7 + internal/platform/db/migrate.go | 89 +++++++------ internal/platform/db/migratelock.go | 124 ++++++++++++++++++ .../db/migratelock_integration_test.go | 120 +++++++++++++++++ 4 files changed, 301 insertions(+), 39 deletions(-) create mode 100644 internal/platform/db/export_test.go create mode 100644 internal/platform/db/migratelock.go create mode 100644 internal/platform/db/migratelock_integration_test.go 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/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) + } +} From 2df60f9def6279cb15c25c564b2a7839a1b6a12d Mon Sep 17 00:00:00 2001 From: Ahmustufa Date: Thu, 24 Sep 2026 15:25:56 +0500 Subject: [PATCH 6/6] docs(sequences): renumber the branching invariant to 86 after the rebase #253 landed invariants 83-85 (the mail transport seam, AUTH negotiation, EHLO validation) while this branch was open, so its own 84 collided. Renumbered to 86 and re-pointed the five code comments that cited it. That last part is the half a textual merge does not catch: main's 84 is now "IMAP and SMTP authentication negotiate a mechanism", so every comment here still saying "invariant 84" would have pointed a reader at an unrelated invariant while reading as correct. This repo has already lost a Critical to a comment whose attribution outlived its truth. stepbranch.sql.go is regenerated from its .sql source rather than hand-edited. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VECELVAKe8Xcp7GH9wGR5t --- internal/app/sequencestep/branch.go | 2 +- internal/coreapi/inprocess/branchroute.go | 2 +- internal/platform/db/gen/stepbranch.sql.go | 2 +- internal/platform/db/queries/stepbranch.sql | 2 +- internal/worker/sequence/branching_integration_test.go | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/app/sequencestep/branch.go b/internal/app/sequencestep/branch.go index 21bd4908..963e6ed9 100644 --- a/internal/app/sequencestep/branch.go +++ b/internal/app/sequencestep/branch.go @@ -108,7 +108,7 @@ func (s *Service) SetBranch(ctx context.Context, ws, campaignID uuid.UUID, in Br // 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 +// overrides that (docs/security.md invariant 86). 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 diff --git a/internal/coreapi/inprocess/branchroute.go b/internal/coreapi/inprocess/branchroute.go index d9da46ca..88993ae1 100644 --- a/internal/coreapi/inprocess/branchroute.go +++ b/internal/coreapi/inprocess/branchroute.go @@ -347,7 +347,7 @@ func (c client) stepSendCreatedAt(ctx context.Context, ws uuid.UUID, b gen.GetSt // // 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 +// only (invariant 86). 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, diff --git a/internal/platform/db/gen/stepbranch.sql.go b/internal/platform/db/gen/stepbranch.sql.go index d81959f1..318ba18a 100644 --- a/internal/platform/db/gen/stepbranch.sql.go +++ b/internal/platform/db/gen/stepbranch.sql.go @@ -66,7 +66,7 @@ type FirstHumanTrackingEventAtParams struct { // 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 +// never disagree about the same contact (docs/security.md invariant 86). 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, diff --git a/internal/platform/db/queries/stepbranch.sql b/internal/platform/db/queries/stepbranch.sql index 7898bd33..5f4c48c3 100644 --- a/internal/platform/db/queries/stepbranch.sql +++ b/internal/platform/db/queries/stepbranch.sql @@ -57,7 +57,7 @@ SELECT tracking_enabled FROM campaigns WHERE id = $1 AND workspace_id = $2; -- 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 +-- never disagree about the same contact (docs/security.md invariant 86). 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 diff --git a/internal/worker/sequence/branching_integration_test.go b/internal/worker/sequence/branching_integration_test.go index c9c0a59a..b316fe47 100644 --- a/internal/worker/sequence/branching_integration_test.go +++ b/internal/worker/sequence/branching_integration_test.go @@ -173,7 +173,7 @@ func TestBranchOpenedRoutesYes(t *testing.T) { } // 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). +// and when the window closes it routes NO (docs/security.md invariant 86). func TestBranchMachineOpenDoesNotCount(t *testing.T) { f, done := seedBranchCampaign(t) defer done()