Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }
Expand Down Expand Up @@ -2238,6 +2244,110 @@ paths:
'401': { description: Unauthorized }
'404': { description: Campaign or step not found }
'409': { description: Campaign not draft (reorder is a structural edit) }
'422':
description: "The new order's fall-through edges would close a loop through a branch (code cycle)"
content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } }
/campaigns/{id}/graph:
get:
operationId: getCampaignGraph
tags: [campaigns]
security: [{ bearerAuth: [] }]
parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
description: >-
The campaign's sequence as a routing graph: one node per step (in
step_order), each with its branch (if any) and the step it falls
through to when it has none. A campaign with no branches is the linear
sequence - every node has branch null and falls through to the next step
by step_order, and the last one ends.


Edges to draw: for a node WITH a branch, its yes_step_id / no_step_id
(null = the path ends there; 'always' has only yes_step_id). For a node
WITHOUT one, default_next_step_id (null = the path ends).
responses:
'200': { description: The graph, content: { application/json: { schema: { $ref: '#/components/schemas/CampaignGraph' } } } }
'400': { description: Campaign id is not a uuid }
'401': { description: Unauthorized }
'403': { description: "Insufficient scope (campaigns:read)" }
'404': { description: Campaign not found }
/campaigns/{id}/steps/{stepId}/branch:
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
- { name: stepId, in: path, required: true, schema: { type: string, format: uuid } }
put:
operationId: setStepBranch
tags: [campaigns]
security: [{ bearerAuth: [] }]
description: >-
Create or replace the branch that routes an enrollment OUT of this step
after it has been sent. One branch per step.


Allowed on a RUNNING campaign (like a step content or variant edit).
Every routing decision uses the graph as it is when the decision is
made: a contact already waiting on this step is evaluated against the
new branch at its next check (at most an hour away), with the window
still measured from when this step was sent. Messages already sent are
never affected.


Conditions are evaluated against THIS step's send. opened / clicked /
not_opened count HUMAN tracking events only (machine prefetches are
ignored), so they are refused with 400 (code tracking_required) unless
the campaign has tracking on and the step and every one of its variants
has an HTML body.


replied / not_replied only ever route on replies whose reply label does
NOT stop the sequence. Reply labels decide what a reply does, and a
branch never overrides them: a reply whose label has
stops_enrollment=true - which by default is EVERY human label
(Interested, Not interested, Neutral, Unclassified, Unsubscribe) -
stops the sequence the moment it arrives, before any branch is
consulted. So with the default labels a replied branch never fires; to
route on replies, turn stops_enrollment off on the label (or create one
that does not stop) and name it in reply_label_key. Naming a stopping
label is refused with 400 (code reply_label_stops_sequence). Without a
label, replied counts any non-automated reply that leaves the sequence
running; out-of-office and auto-replies count only when named.


Refused with 422 (code cycle) when the result would let any path revisit
a step - including a loop closed by another step's linear fall-through -
and with 422 (code unknown_target) when an exit names a step outside
this campaign.
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/StepBranchRequest' } } }
responses:
'200': { description: The saved branch, content: { application/json: { schema: { $ref: '#/components/schemas/StepBranch' } } } }
'400':
description: "Malformed or unusable branch (codes invalid_condition, invalid_within_days, invalid_reply_label, reply_label_stops_sequence, tracking_required, no_exit_not_allowed; cycle for an exit to the step itself). Invalid json or path ids return a plain {error} body."
content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } }
'401': { description: Unauthorized }
'403': { description: "Insufficient scope (campaigns:write)" }
'404': { description: Campaign or step not found }
'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
Expand Down Expand Up @@ -5041,6 +5151,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 (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 }
StepBranchRequest:
type: object
required: [condition]
properties:
condition: { $ref: '#/components/schemas/StepBranchCondition' }
within_days: { type: integer, minimum: 1, maximum: 90, nullable: true, description: "Required for every condition except always; must be absent or null for always" }
reply_label_key: { type: string, nullable: true, description: "Optional, replied / not_replied only. Must name a reply label key in the workspace whose stops_enrollment is false (a stopping label ends the sequence before any branch runs). Empty string is treated as null." }
yes_step_id: { type: string, format: uuid, nullable: true, description: "A step of the same campaign, not this step; null or absent ends the path" }
no_step_id: { type: string, format: uuid, nullable: true, description: "As yes_step_id; must be null or absent for always" }
CampaignGraphNode:
type: object
required: [step_id, step_order, default_next_step_id, branch]
properties:
step_id: { type: string, format: uuid }
step_order: { type: integer }
default_next_step_id: { type: string, format: uuid, nullable: true, description: "The next step by step_order - where this step goes when branch is null. Null for the last step. Present even when a branch overrides it." }
branch:
allOf: [{ $ref: '#/components/schemas/StepBranch' }]
type: object
nullable: true
description: "The step's branch, or null for linear fall-through to default_next_step_id."
CampaignGraph:
type: object
required: [campaign_id, entry_step_id, nodes]
properties:
campaign_id: { type: string, format: uuid }
entry_step_id: { type: string, format: uuid, nullable: true, description: "The first step (lowest step_order) every enrollment starts at; null for a campaign with no steps" }
nodes: { type: array, items: { $ref: '#/components/schemas/CampaignGraphNode' } }
BranchValidationError:
type: object
required: [error, code]
properties:
error: { type: string, description: Human-readable message }
code:
type: string
enum: [invalid_condition, invalid_within_days, invalid_reply_label, reply_label_stops_sequence, tracking_required, no_exit_not_allowed, unknown_step, unknown_target, cycle]
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]
Expand Down
2 changes: 1 addition & 1 deletion cmd/inroad/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/deploy/aws-terraform.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/deploy/docker-compose.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
4 changes: 4 additions & 0 deletions docs/src/content/docs/deploy/kubernetes-helm.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
56 changes: 43 additions & 13 deletions docs/src/content/docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -2269,21 +2269,51 @@ 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.

**Reply evidence is thread-scoped, not sender-verified.** A reply counts
because the inbox poller matched it to one of this campaign's sends by its
In-Reply-To/References headers and stored it on the enrollment's campaign +
contact thread — the same matching MarkReplied uses. Nothing proves the
CONTACT wrote it: anyone who knows a real Message-ID of a send can place a
message in that thread (the within-workspace spoofing gap listed under
Deferred). The blast radius is the same as that gap's and no wider: it can
route one enrollment of the workspace down a branch it could already be
routed down, and it cannot reach another tenant, suppress anything, or make
a stopping label's reply do anything but stop.

**Tenancy.** Every branch read and write is `workspace_id`-pinned, and every
step reference (source and both exits) is a composite FK on
`(id, campaign_id)` with `(campaign_id, workspace_id)` pinned to `campaigns`,
so a branch pointing into another campaign or tenant is unrepresentable even
for a write that skips the service. Graph writes (branch upsert/delete, step
delete, reorder) run under a per-campaign `FOR NO KEY UPDATE` lock that
matches zero rows for a foreign workspace (404) and re-validate acyclicity
inside the transaction. The send path has a runtime loop backstop that ends
the path rather than recovering-forward forever.

**A branch never overrides reply-label automation.** A reply whose label
stops the enrollment still stops it; compliance dispatch (invariants 20, 45)
is untouched. A branch only routes enrollments the labels leave active, and
the save path refuses a reply branch that names a stopping label
(`reply_label_stops_sequence`), since it could never fire. A paused, draft
or done campaign's enrollments are not routed at all: the not-running gate
runs BEFORE routing, so a hold never finishes or parks an enrollment.

## Deferred (documented, not yet built)
- **Conditional branching on a sequence step must gate on HUMAN events only**
(invariant 63). This is written down BEFORE the feature exists because getting
it wrong is silent: a scanner's prefetch would fire an "if opened" branch and
send the contact the wrong follow-up, with nothing in the UI to show that a bot
rewrote a real sequence. A branch predicate must read the stored verdict —
`... AND kind = 'open' AND NOT is_machine`, the same filter `CountHumanOpens`
uses — and must never re-derive its own definition of an open, or the branch
and the reported open rate will disagree about the same contact. Two further
cautions: a `human` verdict is "not obviously a machine", so a branch whose
wrong side is expensive or irreversible should prefer the negative path; and
because the verdict is computed once at write time, rows recorded before that
migration are all marked human and a branch reading old history will
over-fire.
- Datacenter/cloud IP ranges as a refreshed table (AWS/GCP/Azure publish
machine-readable lists; Apple's MPP relay egress likewise). `botfilter`'s
compiled-in range list covers only what is knowable from the address itself,
Expand Down
Loading
Loading