Skip to content
Draft
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
208 changes: 208 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,140 @@ paths:
'401': { description: Unauthorized }
'404': { description: Campaign or step not found }
'409': { description: Campaign not draft (reorder is a structural edit) }
'422':
description: "The new order's fall-through edges would close a loop through a branch (code cycle)"
content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } }
/campaigns/{id}/graph:
get:
operationId: getCampaignGraph
tags: [campaigns]
security: [{ bearerAuth: [] }]
parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
description: >-
The campaign's sequence as a routing graph: one node per step (in
step_order), each with its branch (if any) and the step it falls
through to when it has none. A campaign with no branches is the linear
sequence - every node has branch null and falls through to the next step
by step_order, and the last one ends.


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


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


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


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


Refused with 422 (code cycle) when the result would let any path revisit
a step - including a loop closed by another step's linear fall-through -
and with 422 (code unknown_target) when an exit names a step outside
this campaign.


Optimistic concurrency. Without expected_updated_at the write replaces
whatever branch the step has (last writer wins). With it, the write
applies only if the step is still as the client last saw it - null
means "the step has no branch" (create-only), a timestamp means "the
branch's updated_at is still exactly this". Otherwise nothing is written
and the response is 409 (code branch_changed) carrying the branch as it
is now in current (null if the step has none). The precondition is
checked after the body is validated.
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/StepBranchRequest' } } }
responses:
'200': { description: The saved branch, content: { application/json: { schema: { $ref: '#/components/schemas/StepBranch' } } } }
'400':
description: "Malformed or unusable branch (codes invalid_condition, invalid_within_days, invalid_reply_label, reply_label_stops_sequence, tracking_required, no_exit_not_allowed; cycle for an exit to the step itself). Invalid json or path ids return a plain {error} body."
content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } }
'401': { description: Unauthorized }
'403': { description: "Insufficient scope (campaigns:write)" }
'404': { description: Campaign or step not found }
'409':
description: "expected_updated_at no longer holds - the branch was created, replaced or removed since it was read (code branch_changed, with current)"
content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } }
'422':
description: "The graph refuses the edit (codes cycle, unknown_target)"
content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } }
delete:
operationId: deleteStepBranch
tags: [campaigns]
security: [{ bearerAuth: [] }]
description: >-
Remove the step's branch, returning it to linear fall-through (the next
step by step_order). Idempotent without expected_updated_at. Allowed on a
running campaign; a contact waiting on the removed condition proceeds to
the fall-through step, which still waits out its own delay_seconds from
this step's send. Refused with 422 (code cycle) if the restored
fall-through would close a loop.


With expected_updated_at the branch is removed only if its updated_at
is still exactly that value; otherwise 409 (code branch_changed) with
the branch as it is now in current - null when someone else already
removed it, which a client may treat as done.
parameters:
- name: expected_updated_at
in: query
required: false
description: "The branch's updated_at exactly as this API returned it (URL-encoded). Omit for an unconditional delete."
schema: { type: string, format: date-time }
responses:
'204': { description: Removed (or there was none) }
'400': { description: "expected_updated_at is not a timestamp this API could have returned" }
'401': { description: Unauthorized }
'403': { description: "Insufficient scope (campaigns:write)" }
'404': { description: Campaign or step not found }
'409':
description: "expected_updated_at no longer holds (code branch_changed, with current)"
content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } }
'422':
description: The restored fall-through would close a loop (code cycle)
content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } }
/campaigns/{id}/launch:
post:
operationId: launchCampaign
Expand Down Expand Up @@ -5041,6 +5181,74 @@ components:
type: array
description: the FULL ordered list of the campaign's step ids, in the desired order
items: { type: string, format: uuid }
StepBranchCondition:
type: string
enum: [always, opened, clicked, replied, not_opened, not_replied]
description: >-
What the branch tests, against the step it is attached to.
always - unconditional: go to yes_step_id (null = end the path).
opened / clicked - a HUMAN open/click of this step within within_days
of its send (yes as soon as it happens; no when the window closes).
replied - a counted reply within the window (yes as soon as it arrives).
not_opened / not_replied - the negation: no as soon as the event
happens, yes when the window closes without it.
StepBranch:
type: object
required: [step_id, condition, within_days, reply_label_key, yes_step_id, no_step_id, updated_at]
properties:
step_id: { type: string, format: uuid, description: The step this branch routes out of }
condition: { $ref: '#/components/schemas/StepBranchCondition' }
within_days: { type: integer, minimum: 1, maximum: 90, nullable: true, description: "Evaluation window in days after the step's send; null exactly when condition is always" }
reply_label_key: { type: string, nullable: true, description: "replied / not_replied only: count only replies classified with this reply label key (always a label that does not stop the sequence)" }
yes_step_id: { type: string, format: uuid, nullable: true, description: "Where a true condition (or always) goes; null ends the path" }
no_step_id: { type: string, format: uuid, nullable: true, description: "Where a false condition goes; null ends the path; always null for always" }
updated_at: { type: string, format: date-time, description: "When the branch last changed, at microsecond precision. Also its concurrency token - send it back verbatim as expected_updated_at (re-formatting through a millisecond clock such as a JS Date loses digits and never matches). Advances on every change, including an exit nulled because its target step was deleted." }
StepBranchRequest:
type: object
required: [condition]
properties:
condition: { $ref: '#/components/schemas/StepBranchCondition' }
within_days: { type: integer, minimum: 1, maximum: 90, nullable: true, description: "Required for every condition except always; must be absent or null for always" }
reply_label_key: { type: string, nullable: true, description: "Optional, replied / not_replied only. Must name a reply label key in the workspace whose stops_enrollment is false (a stopping label ends the sequence before any branch runs). Empty string is treated as null." }
yes_step_id: { type: string, format: uuid, nullable: true, description: "A step of the same campaign, not this step; null or absent ends the path" }
no_step_id: { type: string, format: uuid, nullable: true, description: "As yes_step_id; must be null or absent for always" }
expected_updated_at: { type: string, format: date-time, nullable: true, description: "Optional precondition. Absent - no check (last writer wins). null - apply only if the step has no branch. A timestamp - apply only if the branch's updated_at is still exactly this (the StepBranch.updated_at value verbatim). A failed check is 409 code branch_changed." }
CampaignGraphNode:
type: object
required: [step_id, step_order, default_next_step_id, branch]
properties:
step_id: { type: string, format: uuid }
step_order: { type: integer }
default_next_step_id: { type: string, format: uuid, nullable: true, description: "The next step by step_order - where this step goes when branch is null. Null for the last step. Present even when a branch overrides it." }
branch:
allOf: [{ $ref: '#/components/schemas/StepBranch' }]
type: object
nullable: true
description: "The step's branch, or null for linear fall-through to default_next_step_id."
CampaignGraph:
type: object
required: [campaign_id, entry_step_id, nodes]
properties:
campaign_id: { type: string, format: uuid }
entry_step_id: { type: string, format: uuid, nullable: true, description: "The first step (lowest step_order) every enrollment starts at; null for a campaign with no steps" }
nodes: { type: array, items: { $ref: '#/components/schemas/CampaignGraphNode' } }
BranchValidationError:
type: object
required: [error, code]
properties:
error: { type: string, description: Human-readable message }
code:
type: string
enum: [invalid_condition, invalid_within_days, invalid_reply_label, reply_label_stops_sequence, tracking_required, no_exit_not_allowed, unknown_step, unknown_target, cycle, branch_changed]
step_ids:
type: array
items: { type: string, format: uuid }
description: "code cycle only: the steps on the loop, in path order"
current:
allOf: [{ $ref: '#/components/schemas/StepBranch' }]
type: object
nullable: true
description: "code branch_changed only, and always present then: the step's branch as it is now, or null if it has none"
CreateCampaignRequest:
type: object
required: [name, mailbox_id, list_id, subject]
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
Loading
Loading