Skip to content

5.0 Upgrade Guide

In this document we describe important changes to be aware of when upgrading from orchestrator-core v4.x to v5.0.

If you are not yet using v4.x, please consult the 4.0 upgrade guide first.

If you are using scheduled tasks and/or workflow authorization callbacks, please consult the 4.7 and 4.8 upgrades first as well.

About 5.0

In this release, the following breaking changes are introduced:

  1. GraphQL scheduledTasks query no longer returns task created by the decorator
  2. Secret settings for sensitive URIs
  3. Dependency pydantic-forms lower bound increased from 1.4.0 to 2.4.0
  4. DB column engine_settings.running_processes removed, service functions renamed
  5. The description parameter in workflow decorators is now deprecated
  6. strawberry-graphql upgrade includes breaking changes

1. GraphQL scheduledTasks query no longer returns task created by the decorator

The GraphQL scheduledTasks query no longer returns task created by the decorator, but only tasks that are scheduled via the API.

To see all scheduled tasks, you can run the show-schedule CLI command which will still show

2. Secret settings for sensitive URIs

Replace usages of orchestrator.utils.expose_settings.SecretStr with pydantic.SecretStr (previously we also called this type OrchSecretStr)

Three AppSettings fields that contain sensitive connection strings have been changed from plain types to Pydantic Secret subtypes. These URIs can contain credentials (e.g. database passwords, Redis auth tokens) that should not be leaked. By wrapping them in Secret types, Pydantic automatically masks their values when the settings object is logged, printed, serialized, or appears in tracebacks — replacing the actual value with '**********'. This provides better protection against accidental credential exposure in application logs and error reports.

The affected fields and their new types are:

Field Old Type New Type
DATABASE_URI PostgresDsn SecretPostgresDsn (Secret[PostgresDsn])
CACHE_URI RedisDsn SecretRedisDsn (Secret[RedisDsn])
WEBSOCKET_BROADCASTER_URL str SecretStr

What changed

Previously, you could use these settings values directly:

from orchestrator.settings import app_settings

# Old usage — no longer works
db_url = str(app_settings.DATABASE_URI)
redis_url = str(app_settings.CACHE_URI)
ws_url = app_settings.WEBSOCKET_BROADCASTER_URL

Now, you must call .get_secret_value() to retrieve the underlying value:

from orchestrator.settings import app_settings

# New usage
db_url = str(app_settings.DATABASE_URI.get_secret_value())
redis_url = str(app_settings.CACHE_URI.get_secret_value())
ws_url = app_settings.WEBSOCKET_BROADCASTER_URL.get_secret_value()

What you need to do

Search your codebase for any direct references to app_settings.DATABASE_URI, app_settings.CACHE_URI, or app_settings.WEBSOCKET_BROADCASTER_URL (or via a settings parameter of type AppSettings). At each usage site, append .get_secret_value() to extract the actual value.

For example:

# Before
config.set_main_option("sqlalchemy.url", str(app_settings.DATABASE_URI))
cache = create_redis_client(app_settings.CACHE_URI)
broadcaster_type = urlparse(app_settings.WEBSOCKET_BROADCASTER_URL).scheme

# After
config.set_main_option("sqlalchemy.url", str(app_settings.DATABASE_URI.get_secret_value()))
cache = create_redis_client(app_settings.CACHE_URI.get_secret_value())
broadcaster_type = urlparse(app_settings.WEBSOCKET_BROADCASTER_URL.get_secret_value()).scheme

If you assign to these fields in tests or configuration (e.g. overriding DATABASE_URI with a test database URL), wrap the value in the appropriate Secret type:

from orchestrator.settings import SecretPostgresDsn, SecretRedisDsn, app_settings
from pydantic import SecretStr

# Before
app_settings.DATABASE_URI = db_uri

# After
app_settings.DATABASE_URI = SecretPostgresDsn(db_uri)

Warning

Previously custom settings whose names contained the string secret or password would automatically be masked. This is no longer the case, and you should now use the type SecretStr for these values instead.

3. Dependency pydantic-forms lower bound increased from 1.4.0 to 2.4.0

The lower bound for dependency pydantic-forms has increased from 1.4.0 to 2.4.0. This includes one breaking change from version 2.0, Replace ReadOnlyField with read_only_field and read_only_list.

What changed

Previously, the type ReadOnlyField was used for both scalar fields and list fields.

class Form(FormPage):
    int_field: ReadOnlyField(1)
    bool_field: ReadOnlyField("True", default_type=bool)
    list_field: ReadOnlyField(["a", "b"], default_type=list[str])

As you can see, for bool_field and list_field it was necessary to explicitly specify the type.

Now, you must use a different type for either field, and you no longer have to specify the default_type:

class Form(FormPage):
    int_field: read_only_field(1)
    bool_field: read_only_field(True)
    list_field: read_only_list(["a", "b"])

What you need to do

If you have pydantic-forms pinned to any version, you will need to change this pin to 2.4.0.

If your pinned version was below 2,0, search your codebase for usages of ReadOnlyField() and replace them with read_only_field() or read_only_list() as described above. The new types still come from the same module, pydantic_forms.validators.

4. DB column engine_settings.running_processes removed, service functions renamed

The running_processes column has been removed from the engine_settings database table. Previously, this counter was maintained manually on every process start and finish. This was replaced in v4.8 by WorkerStatusMonitor which continuously determines the running_processes count by inspecting either the threadpool or the celery statistics.

What changed

Several internal service functions have been renamed for clarity:

Old name New name
get_engine_settings() get_engine_settings_table()
get_engine_settings_for_update() get_engine_settings_table_for_update()
generate_engine_status_response() (in api/api_v1/endpoints/settings) generate_engine_settings_schema() (in services/settings)

What you need to do

If you use the running_processes column in external queries, this column will need to be removed as there is no direct replacement.net

You can instead either retrieve this value through the /settings/status REST endpoint, or through the settings GraphQL query.

If you use the old service functions anywhere, replace them with the new ones as described below.

Before

from orchestrator.services.settings import get_engine_settings, get_engine_settings_for_update
from orchestrator.api.api_v1.endpoints.settings import generate_engine_status_response

engine_settings = get_engine_settings()
engine_settings_for_update = get_engine_settings_for_update()
schema = generate_engine_status_response(engine_settings)

After

from orchestrator.services.settings import get_engine_settings_table, get_engine_settings_table_for_update, generate_engine_settings_schema

engine_settings = get_engine_settings_table()
engine_settings_for_update = get_engine_settings_table_for_update()
schema = generate_engine_settings_schema(engine_settings)

5. The description parameter in workflow decorators is now deprecated

The description parameter in @create_workflow, @modify_workflow, @terminate_workflow, @validate_workflow, @reconcile_workflow, @task, and @workflow is now deprecated. Workflow descriptions are now managed in the database and can be updated via the UI or API.

What changed

Previously, workflow descriptions were typically defined both in code and in the database. This could lead to mismatches when the description was updated through the UI. Descriptions are no longer validated against the database by the product validation task.

Passing a description to a workflow decorator still works in v5.0, but now emits a deprecation warning.

What you need to do

Remove the description parameter from your workflow decorators and manage workflow descriptions through the database instead.

For example:

# Before
@create_workflow("Create SN8 IP BGP", initial_input_form=initial_input_form_generator)
def create_sn8_ip_bgp() -> StepList:
    ...

# After
@create_workflow(initial_input_form=initial_input_form_generator)
def create_sn8_ip_bgp() -> StepList:
    ...

If you want to backfill descriptions for multiple existing workflows during the upgrade, you can do so in a one-off database migration. For example, replace the workflow names and descriptions below with your own values:

WITH workflow_descriptions(workflow_name, workflow_description) AS (
    VALUES
        ('your_create_workflow', 'Create your product'),
        ('your_modify_workflow', 'Modify your product'),
        ('your_terminate_workflow', 'Terminate your product')
)
UPDATE workflows AS w
SET description = wd.workflow_description
FROM workflow_descriptions AS wd
WHERE w.name = wd.workflow_name;

6. strawberry-graphql upgrade includes breaking changes

What changed

  • FEDERATION_ENABLED renamed to FEDERATION_VERSION, defaulting to 2.9 to maintain compatibility with example-orchestrator. (strawberry-graphql 0.285.0 breaking change)
  • SERVE_GRAPHQL_UI changed from bool to a string (leaving it empty turns it off): graphiql, apollo-sandbox or pathfinder.
  • Custom scalars no longer accept NewType, use name= and map to the target type instead. (strawberry scalar docs)

What you need to do

remove FEDERATION_ENABLED and set FEDERATION_VERSION to the federation version you need.

Change SERVE_GRAPHQL_UI to the UI you want to use or set it to empty SERVE_GRAPHQL_UI=""

If you have custom scalars, replace the NewType wrapper with name=:

Before:

import strawberry
from typing import NewType
from orchestrator.graphql import SCALAR_OVERRIDES

VlanRangesType = strawberry.scalar(
    NewType("VlanRangesType", str),
    description="Represent the Orchestrator VlanRanges data type",
    serialize=lambda v: v.to_list_of_tuples(),
    parse_value=lambda v: v,
)

After:

import strawberry
from orchestrator.graphql import SCALAR_OVERRIDES

VlanRangesType = strawberry.scalar(
    name="VlanRangesType",
    description="Represent the Orchestrator VlanRanges data type",
    serialize=lambda v: v.to_list_of_tuples(),
    parse_value=lambda v: v,
)

The NewType wrapper and from typing import NewType are no longer needed and can be removed.

7. LLM-powered search is now the default

What changed

The LLM-powered search (AI search) is now a mandatory part of orchestrator-core. The following changes were made:

  • SEARCH_ENABLED setting removed – search is always enabled. Remove this environment variable from your configuration.
  • search pip extra removedlitellm is now a core dependency. Change pip install orchestrator-core[search] to just pip install orchestrator-core.
  • Search tables managed by alembic – the ai_search_index, agent_runs, search_queries, and graph_snapshots tables are now created via an alembic migration instead of at application startup.
  • TSV search deprecated – the query parameter on the subscriptions REST and GraphQL endpoints now emits a deprecation warning. Use the /api/search endpoint instead. TSV search will be removed in a future 5.x release.

What you need to do

1. Prerequisites: install required PostgreSQL extensions

The AI search migration requires several PostgreSQL extensions. If your database user has sufficient privileges, the migration will create them automatically. Otherwise, install them manually before running the migration:

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS ltree;
CREATE EXTENSION IF NOT EXISTS unaccent;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS vector;

The vector extension requires pgvector to be installed on your PostgreSQL server. The easiest way to get a PostgreSQL instance with all required extensions is to use the pgvector/pgvector Docker image, which the example-orchestrator uses in its docker-compose.yml:

postgres:
  image: pgvector/pgvector:pg17

This image is based on the official PostgreSQL image and includes pgvector pre-installed. The other extensions (uuid-ossp, ltree, unaccent, pg_trgm) are part of the standard PostgreSQL contrib package and are available in all standard PostgreSQL distributions.

If your database user cannot create extensions but the extensions already exist, no action is needed. If you need to force extension creation (e.g., after a PostgreSQL upgrade), set LLM_FORCE_EXTENSION_MIGRATION=true.

2. Run the database migration

python main.py db upgrade heads

This creates the AI search tables. For existing installations that already have these tables (from the runtime migration), the migration uses CREATE TABLE IF NOT EXISTS and is safe to run.

3. Configure your embedding provider (optional)

Search works without any embedding configuration, but semantic search (vector similarity) will be disabled. Text-based search will still function normally.

To enable semantic search, set the following environment variables:

EMBEDDING_API_KEY=sk-...       # Your embedding provider's API key
EMBEDDING_MODEL=openai/text-embedding-3-small  # Default; see LiteLLM docs for alternatives
EMBEDDING_DIMENSION=1536       # Default; adjust based on your model

EMBEDDING_API_BASE is optional. When omitted, the default API base URL of the provider (as configured by LiteLLM) is used. Set it to point to a custom or self-hosted endpoint:

EMBEDDING_API_BASE=https://your-custom-endpoint.example.com/v1  # Optional

4. Initialize the search index

After the migration, populate the search index by running the CLI indexing commands:

python main.py orchestrator index subscriptions
python main.py orchestrator index products
python main.py orchestrator index processes
python main.py orchestrator index workflows

5. (Optional) Resize embeddings

If you want to use a different embedding dimension than the default (1536), update EMBEDDING_DIMENSION and run the resize command:

python main.py orchestrator embedding resize

Warning

Resizing embeddings will delete all existing embeddings from ai_search_index and search_queries tables. You will need to re-index after resizing.

6. Remove deprecated settings

Remove these environment variables from your configuration:

  • SEARCH_ENABLED – no longer used
  • OPENAI_API_KEY – renamed to EMBEDDING_API_KEY
  • OPENAI_BASE_URL – renamed to EMBEDDING_API_BASE
  • Any pip install commands that use orchestrator-core[search] – change to orchestrator-core

8. orchestrator.security.authorize and authorize_websocket now raise

What changed

These callbacks determine if a user is authorized to make a request or connect a websocket.

OrchestratorCore.register_authorization allows users to register their own Authorization instance. This could be the oauth2_lib default OPA-based instance, but with auto_error=False, or a custom Authorization instance entirely.

Previously, orchestrator-core assumed this instance inherited from oauth2_lib’s OPAMixin with auto_error=True, and did not raise when False was returned from auth_manager.authorize.

Now it does raise on False.


What you need to do

If you write your own instance of an Authorization that returns False instead of raising a 403 Forbidden, this may cause your authorizations to start working (i.e. 403ing on failure) where they previously didn’t. You should confirm your authorizations work as expected.

9. Workflow and Step callbacks of type Authorizer now expect an AuthContext, not an OIDCUserModel

What changed

These callbacks (of type Authorizer) determine if a user is authorized to start, resume, or retry a workflow.

Previously, these callbacks should have received an argument of oauth2_lib.fastapi.OIDCUserModel. Now, they should receive an AuthContext.

OrchestratorCore.register_authorization allows users to register their own Authorization instance. This could be the oauth2_lib default OPA-based instance, but with auto_error=False, or a custom Authorization instance entirely.

Previously, orchestrator-core assumed this instance inherited from oauth2_lib’s OPAMixin with auto_error=True, and did not raise when False was returned from auth_manager.authorize.

Now it does raise on False.

It looks like this:

class AuthContext(BaseModel):
    model_config = ConfigDict(arbitrary_types_allowed=True)
    user: AuthUserModel | None = None
    action: Literal["start_workflow", "resume_workflow", "retry_workflow"]
    workflow: AuthWorkflow | None = None
    # Should be None for "start_workflow", otherwise set
    step: AuthStep | None = None

What you need to do

You are affected by this change if you use any of the following:

  • @workflow(..., auth_callback=foo, retry_auth_callback=bar)
  • @step(..., resume_auth_callback=foo, retry_auth_callback=bar)

There should now be much more information available for making authorization decisions within a callback, but here is how to quickly port your existing logic. If you currently have:

def auth_callback(user: OIDCUserModel | None) -> bool:
    return do_something(user)

You would update this function to instead do:

from orchestrator.utils.auth import AuthContext

def auth_callback(context: AuthContext | None) -> bool:
    return do_something(context.user)

To leverage the new changes, see the fields on this model and its sub-Protoypes.