Reference

Deployments

The dispatch unit between modules and flows. Wraps every published flow with a stable ID, canary/A-B variant routing, an enable/disable toggle, and a contract describing what the flow contributes to /openapi.json + /asyncapi.json.

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:

KindPurposeStable ID convention
routeHTTP-mounted flow — dispatcher binds (method, path) and serves requests through the variant chainroute-<method>-<path-slug> (e.g. route-post-api-v1-users-signup)
subscriberEvent-driven flow — variants subscribe to a bus channel, optionally filteredsubscriber-<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:

text
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.

yaml
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 + 10 and 9 + 1 produce 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 in RunInput.Meta whose value is hashed for sticky bucketing — the same session_id always 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:

shell
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.json generator 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.

Why silent removal from specs. Disabled = "not currently serving". The spec describes what the project actually serves; advertising a route that 404s would mislead client SDK generators and /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 — create
  • PATCH /api/v1/deployments/{id} — when variants are part of the patch
  • POST /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:

json
{
  "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.

json
{
  "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:

json
{
  "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:

ContributionSurfaces 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:

shell
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:

json
{
  "id": "users-registration-email-confirm",
  ...
  "meta": {
    "publish_with": ["users/email-verify-handler"]
  }
}

When the operator activates users/registration-email-confirm:

  1. ActivateTemplate publishes the registration flow + creates route-post-api-v1-users-signup deployment.
  2. Walks meta.publish_with → calls EnsureTemplatePublished for each — idempotent, reuses already-running handler flows.
  3. Each handler dependency that wasn't already published creates its subscriber-<template-id> deployment.
  4. The activation response echoes which deps were freshly published:
json
{
  "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

MethodPathPurpose
GET/api/v1/deploymentsList all deployments with kind, tags, disabled, contributes enrichment
GET/api/v1/deployments/{id}Full deployment with same enrichment
POST/api/v1/deploymentsCreate — 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-templateAppend 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 Frame metrics (run rate, latency p50/95/99, error rate, saturation, cost-per-hour) already exist — keyed by flowID in flowexec/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_URL is set in infra/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 disabled flag 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.