Deployments
Deployments
A deployment is the routing record that wires a flow into the project's runtime. Every published flow has exactly one deployment. Two flavours, one shape:
| Kind | Purpose | Stable ID convention |
|---|---|---|
route | HTTP-mounted flow — dispatcher binds (method, path) and serves requests through the variant chain | route-<method>-<path-slug> (e.g. route-post-api-v1-users-signup) |
subscriber | Event-driven flow — variants subscribe to a bus channel, optionally filtered | subscriber-<template-slug> (e.g. subscriber-users-email-verify-handler) |
The deployment is the unit operators reason about — /flows/deployments
is the canonical "what's running in this project" view. Flows are the
implementation behind the variants; the dispatch unit and the contract
live on the deployment.
When deployments get created
Two paths, both auto-managed and idempotent:
Activate registration template Publish a non-HTTP flow
────────────────────────── ──────────────────────────
flowexec.ActivateTemplate flowexec.EnsureTemplatePublished
│ │
│ extract http-endpoint config │ detect non-HTTP source
│ │
▼ ▼
ensureRouteDeployment ensureSubscriberDeployment
ID: route-<method>-<path> ID: subscriber-<template-id>
Variants: [{stable, flowID}] Variants: [{stable, flowID}]
Labels: x-route, x-source Labels: x-event, x-event-filter,
x-template-id, x-kind: subscriber
The IDs are deterministic from the route or template — re-publishing patches the existing deployment's stable variant in place; canary / A-B variants added by operators are preserved untouched.
You never need to create deployments manually for the common cases.
The two paths above cover every template-backed flow. Manual
POST /api/v1/deployments is for the rare case where a project ships
a hand-built deployment definition (typically: pinning a specific
version, custom routing weights, custom labels).
Variants and routing
Each deployment carries one or more variants. The stable variant is
the canonical implementation; canary / A-B / experiment variants are
named however the operator wants. Each variant points at a flowId —
optionally a pinned versionId.
variants:
- label: stable
flowId: flow.0645041f-… # always-published, follows its own publish pointer
weight: 90
- label: canary
flowId: flow.8713d6f7-… # experimental new flow
versionId: ver.df501b50-… # pinned — independent of flow's publish pointer
weight: 10
Routing semantics:
- Weights are relative — the runtime normalises across variants at
selection time.
90 + 10and9 + 1produce the same split. - Zero-weight variants are declared but not selected — useful for staging a variant before flipping its weight.
stickyBy(deployment-level) names a field inRunInput.Metawhose value is hashed for sticky bucketing — the samesession_idalways routes to the same variant. Empty = non-sticky random draw weighted by share.
The Disabled flag
Each deployment has a disabled bool (default false = enabled).
Toggle without deletion:
PATCH /api/v1/deployments/{id}
Content-Type: application/json
{"disabled": true}
When disabled = true:
- Route deployments — dispatcher unbinds the
(method, path). Next request falls through to whatever's registered after flowexec (typically a chi 404). The/openapi.jsongenerator skips the route too — disabled means "not part of the published surface right now". - Subscriber deployments —
DynamicChannels()skips the deployment's variants when emitting AsyncAPI; the event bus consumer is no longer announced. Flows that were subscribed via this deployment unsubscribe on the next sync. - The PATCH triggers
SyncFlowBindings()for every variant flow so the change takes effect on the very next request — no restart.
Re-enable with {"disabled": false} and the same sync rebinds /
re-announces.
/reference
viewers. If you want a "503 maintenance" semantic instead of a hard
remove, that's a separate variant on disable (not implemented yet —
file a request).Route-uniqueness invariant
A (method, path) route is owned by exactly one deployment at a
time, regardless of disabled state. Disabled deployments still hold
their claim — re-enabling can never produce a half-bound state where
two deployments race for the same path.
Enforced at:
POST /api/v1/deployments— createPATCH /api/v1/deployments/{id}— whenvariantsare part of the patchPOST /api/v1/flows/templates/activate— pre-flight check (the auto-managed deployment id is exempt; user-created deployments claiming the same route block activation)
Conflict response is 409 Conflict with structured body:
{
"detail": "route POST /api/v1/users/signup is already claimed by deployment \"route-post-api-v1-users-signup\" — re-bind or delete that deployment before creating one at the same route, OR edit its variants if you want to swap the flow",
"conflicting_deployment_id": "route-post-api-v1-users-signup",
"conflicting_deployment_name": "Registration — basic",
"method": "POST",
"path": "/api/v1/users/signup"
}
Subscriber deployments don't share this constraint — multiple flows subscribing to the same event channel is legitimate fan-out and remains supported.
Spec contributions
/api/v1/deployments and /api/v1/deployments/{id} enrich each
deployment with a contributes block — what it adds to the project's
published API surface. Computed at request time from the stable
variant's flow document so what the UI shows IS what
/openapi.json + /asyncapi.json publish.
{
"id": "route-post-api-v1-users-signup",
"name": "Registration — email confirmation",
"kind": "route",
"disabled": false,
"tags": ["auto-publish"],
"variants": [{ "label": "stable", "flowId": "flow.…", "weight": 100 }],
"contributes": {
"routes": [
{
"method": "POST",
"path": "/api/v1/users/signup",
"success_status": 202,
"summary": "Public signup with email confirmation"
}
],
"events": [],
"packets": [
{ "name": "UserCreateInput", "$ref": "openapi:default#/components/schemas/UserCreateInput" },
{ "name": "UserResponse", "$ref": "openapi:default#/components/schemas/UserResponse" }
]
}
}
A subscriber deployment's contributions are events + packets:
{
"id": "subscriber-users-email-verify-handler",
"kind": "subscriber",
"tags": ["auto-publish", "subscriber", "users/email-verify-handler", "verification.token_verified"],
"contributes": {
"events": [
{ "name": "verification.token_verified", "filter": "payload.kind == \"email\"", "direction": "subscribe" }
]
}
}
The three contribution kinds map to the three published-spec consumers:
| Contribution | Surfaces in |
|---|---|
routes | /openapi.json#/paths/... |
events | /asyncapi.json#/channels/... |
packets | /openapi.json or /asyncapi.json #/components/... (depending on the $ref namespace) |
Tags
Deployments expose a flat tags []string derived from their labels:
- Labels with the
tag:prefix → surfaced as a tag (tag:owner=ops→owner=ops). - Canonical labels (
x-source,x-template-id,x-event,x-kind) → surfaced as tags so admins can filter on them without learning the label vocabulary.
The /flows/deployments UI shows clickable tag chips that toggle a
filter — admins can scope to "show me everything tagged
users/email-verify-handler" or "show me every auto-published
deployment" with one click.
To add custom tags, set them via labels:
PATCH /api/v1/deployments/{id}
{"labels": {"tag:owner": "ops", "tag:env": "production"}}
Lifecycle from meta.publish_with
A registration template can declare runtime dependencies that get auto-published alongside it:
{
"id": "users-registration-email-confirm",
...
"meta": {
"publish_with": ["users/email-verify-handler"]
}
}
When the operator activates users/registration-email-confirm:
ActivateTemplatepublishes the registration flow + createsroute-post-api-v1-users-signupdeployment.- Walks
meta.publish_with→ callsEnsureTemplatePublishedfor each — idempotent, reuses already-running handler flows. - Each handler dependency that wasn't already published creates its
subscriber-<template-id>deployment. - The activation response echoes which deps were freshly published:
{
"flow_id": "flow.d52767d6-…",
"template_id": "users/registration-email-confirm",
"method": "POST",
"path": "/api/v1/users/signup",
"published_with": ["users/email-verify-handler"]
}
published_with is empty when every dep was already running — the
admin UI uses this to show a quieter toast on re-activations.
This is how confirmation-style signup ships intact: activate
registration-email-confirm → email-verify-handler becomes an
event subscriber automatically → user clicks the verification link →
account flips to active. No manual handler-publishing step.
Admin endpoints
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/deployments | List all deployments with kind, tags, disabled, contributes enrichment |
GET | /api/v1/deployments/{id} | Full deployment with same enrichment |
POST | /api/v1/deployments | Create — fails 409 if any variant claims an already-owned route |
PATCH | /api/v1/deployments/{id} | Patch any field; disabled triggers re-sync; variants triggers route-conflict check |
DELETE | /api/v1/deployments/{id} | Delete (requires admin) |
POST | /api/v1/deployments/{id}/variants/from-template | Append a canary variant created from a template in one call |
All paths require auth.RequireActiveUser + auth.RequirePermission("admin:access").
Statistics (deferred)
Per-deployment usage statistics are scoped for a future iteration:
- Per-flow
Framemetrics (run rate, latency p50/95/99, error rate, saturation, cost-per-hour) already exist — keyed byflowIDinflowexec/metrics/types.go. - Aggregating per-deployment requires a weighted rollup across
variants; the foundation is in place (deployment ID → variants →
flow IDs → metrics) and ClickHouse archival is a contained follow-up
when
CLICKHOUSE_URLis set ininfra/docker-compose.yml.
When wired, the /flows/deployments/{id} page gets a sparkline +
headline KPIs; the list page gets per-row inline traffic numbers.
Design rationale
- Deployment as the dispatch unit. Flows are the implementation; deployments are the contract. Operators reason about deployments ("which signup flow is live?", "is the email handler subscribed?") without needing to know which flow ID is currently behind the variant. SDK generators iterate deployments, not flows.
- Idempotent IDs. Deterministic IDs (
route-<method>-<path>,subscriber-<template-id>) mean the auto-managed paths never spawn duplicates. Re-activating a template patches the existing deployment; re-publishing a handler reuses the same subscriber deployment. - Disabled ≠ deleted. The
disabledflag is the soft-pause semantic operators need for maintenance windows + emergency rollback. Re-enabling is one PATCH with no flow loss. Deletion is the destructive escape hatch — the UI nudges towards Disable for reversible workflows. - Route-uniqueness is structural. Two deployments claiming the
same
(method, path)is representation-impossible regardless of activation state. Avoids the entire class of "I disabled X then enabled Y, why is the route still serving X?" bugs. - Subscriber deployments fan-out. Multiple subscriber deployments on the same event channel is legit (fan-out is the point of an event bus). Only routes are unique.
Flow-driven HTTP endpoints
Mount FlowDSL flows as HTTP routes. Domain modules contribute nodes; flowexec owns the HTTP↔flow bridge. Schemas attach to edges as packets (one ref → three rendered forms: stored, /openapi.json, Studio); the http-endpoint source node carries only routing + spec metadata. Flow templates ship in per-module flowtemplates/ packages and auto-publish at startup so endpoints work out of the box.
i18n (translations)
Request-locale resolution, a Mongo-backed message catalog with a locale fallback chain, admin CRUD, and FlowDSL nodes for translating inside flows.