Provisioning workflows
When a tenant subscribes to a ScaiLabs service, something has to happen on the other side — a tenant record gets created in the service, credentials get minted, the service's health gets verified, an email goes out. ScaiControl owns that orchestration through a small workflow engine. This page covers the model.
The four nouns#
| What it is | Lives in | |
|---|---|---|
| Workflow definition | A JSON recipe: ordered steps with dependencies, handler refs, retries, timeouts. One row per (service, type, version). Immutable once any workflow attaches. |
provisioning_workflow_definitions |
| Workflow (instance) | A specific execution of a definition for a specific subscription. Has a status, a snapshot of context (tenant_id, plan, URLs, callback token), and per-step rows. | provisioning_workflows |
| Step | One node in the DAG. Per-step status, retry counters, configured handler, dependency list, mapped inputs, captured outputs. | provisioning_workflow_steps |
| Handler | The Python class that actually does something. Built-in (ValidateDependencies, CallProvisioningApi, …) or shipped as a plugin by a service team via setuptools entry points. |
services/provisioning/handlers/ |
Definitions are templates; workflows are runs; steps are units of execution; handlers are code.
The DAG#
A definition is a directed acyclic graph. Each step declares which other steps it depends_on by name. The scheduler walks the DAG forward: a step becomes runnable the moment all its dependencies are in a terminal "succeeded" state (completed or skipped). Independent branches run in parallel within the same scheduler tick.
Cycles are rejected at validation time (CYCLE), so the graph is guaranteed acyclic by the time the scheduler runs.
Step types#
| Type | When to use it | Handler base class |
|---|---|---|
sync |
The handler completes in one call (preflight check, credential mint, vault write). | StepHandler |
async_callback |
The handler kicks off external work (POST to the service's provisioning API) and waits for the service to POST back to /provisioning/callback with the result. |
AsyncCallbackHandler |
async_poll |
The handler kicks off external work and returns a poll URL — the engine polls it on a schedule until terminal. | AsyncPollHandler |
The state column tracks where each step is in its lifecycle:
A step that fails with retries remaining flips back to pending and is picked up again after retry_delay_seconds. Only when retry_count == max_retries does it become terminally failed.
Inputs and outputs#
Each step's handler reads from an inputs dict and writes to a result dict. Wires are explicit:
1 2 3 4 5 6 7 8 9 10 | |
Source expressions:
<step_name>.<output_key>— pull from an upstream step'sresult. The step must be independs_on(the validator enforces this; the visual designer auto-creates the edge when you pick a source step).context.<key>— pull from the workflow's frozen context (tenant_id,partner_id,service_slug,plan_slug,service_base_url, etc. — see the context spec below).
What outputs a handler produces is declared in the handler's produces_outputs class attribute, surfaced via the handler registry.
Workflow context#
Set once at workflow start, frozen for the lifetime of the run:
| Key | Source |
|---|---|
tenant_id, partner_id, subscription_id, service_slug |
Subscription row + service registry |
plan_slug, plan_tier, plan_id |
Plan associated with the subscription |
service_base_url, provisioning_api_url, health_check_url |
Service registry record |
callback_url |
<API_URL>/api/v1/provisioning/callback |
capabilities, features, quotas |
Catalog snapshots — service capabilities, plan features, plan quotas |
Compensation and rollback#
Every step can declare a compensate_handler — a handler the engine calls in reverse topological order when the workflow is rolled back. Rollback is admin-triggered (POST /admin/provisioning/workflows/{id}/rollback) or automatic on a fatal failed workflow state.
- Steps in
pending/running/waiting_*are markedcancelled(no compensation — they never had a side effect). - Steps in
completedget theircompensate_handlerinvoked, then move torolled_back. - Steps that already
failedare left as-is.
So if gen_creds ran and minted a credential, compensate_handler: "GenerateServiceCredentials" would revoke that credential during rollback. The compensator is just another handler — usually the same class with the compensate() method implemented.
Definitions are versioned#
A definition row is keyed by (service_slug, workflow_type, version). Inserting a new definition for an existing (service, type) auto-bumps the version. The most recent active version is what new workflows attach to.
- Bumping versions is the editing motion: instead of mutating a deployed definition, you create v+1.
- Old workflows keep running against the version they started on — definitions are templates, copies live on workflow rows.
- Deactivating the last active version of a service + type breaks new provisioning of that service. The admin UI and CLI's
deactivatecommand guard against doing it accidentally when subscriptions are present.
The visual designer in /admin/provisioning defaults to "clone & edit" because of this — there's no in-place edit of the step definition once it's been used.
Handler registry#
The engine looks up handler classes by short name ("CallProvisioningApi") at runtime. Three resolution sources, in order:
-
Setuptools entry points under group
scaicontrol.provisioning.handlers— how service teams ship their own handlers. The plugin's package declares them in itspyproject.toml:toml1 2
[project.entry-points."scaicontrol.provisioning.handlers"] BootstrapFlowWorkspace = "scaiflow.provisioning.handlers:BootstrapFlowWorkspace" -
Built-in
common.py— the seven handlers ScaiControl ships out of the box (ValidateDependencies,GenerateServiceCredentials,CallProvisioningApi,CallDeprovisioningApi,StoreCredentialsInVault,HealthCheck,SendProvisioningNotification). -
Fully-qualified class path —
pkg.module.MyHandler. Rare; used when neither short-name path applies.
Discovery happens at scheduler startup. Collisions (two plugins registering the same short name) raise HANDLER_NAME_COLLISION with both source packages named, so it's loud, not silent.
The GET /admin/provisioning/handlers endpoint enumerates everything visible — used by the visual designer's palette + the config_schema autocomplete in the property panel.
Workflow-level lifecycle#
partially_failed is a stuck state — the engine isn't trying anymore, but admin recovery actions (Retry Step, Force Complete) can move it forward.
The visual designer#
/admin/provisioning has three tabs:
- Workflows — running workflow instances. Recovery actions:
Retry Step,Rollback,Force Complete. - Definitions — the templates. Browse, view, clone-and-edit. The editor modal has a Visual and JSON mode that share the same model. Visual mode renders the DAG as SVG with drag-to-reposition, drag-from-port-to-port to connect, and form-rendered config + input_mapping per step.
- Handlers — registry of every discoverable handler with its declared schema, expected inputs, and produced outputs.
For the cookbook take, see Tutorials → Your first provisioning recipe.
Authoring locally#
Recipes live in service repos as provisioning/provision.json + provisioning/deprovision.json. Validate them in CI without a database:
1 | |
Import them into the platform DB once they're clean:
1 2 | |
Full CLI reference at reference/cli.
See also#
- Tutorials: your first provisioning recipe — end-to-end cookbook.
- Reference: state machines — the full lifecycle.
- Reference: admin-provisioning API — every workflow + definition endpoint with request/response schemas.
- Sandbox — local docker-compose stack for safe end-to-end testing.