rototo
DocsLearn
Learn

Operational Switches

Operational switches are the values I reach for when production behavior needs to change quickly, but not casually. During an incident, you may need to stop new project creation while existing projects keep working. That decision should have a diff, review, lint, history, rollback, and a clear answer to "why did this request get blocked?"

That is a good fit for rototo. The switch is not user state, queue state, or an authorization decision. It is reviewed operational policy that the app can refresh and apply at runtime.

I will keep the workspace small: operations-config, with one service boundary that checks whether project creation is enabled.

Start With A Broad Switch

The first version does not need runtime context. Either project creation is enabled for everyone, or it is disabled for everyone.

Create a workspace with one variable:

rototo init operations-config --variable project-creation-enabled

Replace operations-config/variables/project-creation-enabled.toml:

schema_version = 1

description = "Whether accounts can create new projects"
type = "bool"

[values]
enabled = true
disabled = false

[resolve]
default = "enabled"

The variable has two named values, but only one is selected. That naming matters more than it may seem at first. In logs, traces, and reviews, disabled says more than a bare false.

Lint and resolve the switch:

rototo lint operations-config
rototo resolve operations-config --variable project-creation-enabled

Because this switch has no rules yet, rototo evaluates it with {} context and selects the default value:

variable: project-creation-enabled
  pathway:
    default -> enabled
  result:
    value key: enabled
    value: true

Check The Switch In The App

The app should ask rototo for the switch at the boundary where it matters. In this case, that is the project creation path. The app still owns authorization, request validation, and database writes; rototo only answers the operational policy question.

use rototo::{ResolveContext, Workspace};

async fn create_project(workspace: &Workspace) -> Result<(), Box<dyn std::error::Error>> {
    let context = ResolveContext::from_json(serde_json::json!({}))?;
    let resolution = workspace
        .resolve_variable("project-creation-enabled", &context)
        .await?;

    let value_key = resolution.value_key.clone();
    let creation_enabled: bool = serde_json::from_value(resolution.value)?;

    if !creation_enabled {
        println!(
            "project creation blocked by rototo value `{}` from {:?}",
            value_key,
            workspace.source_fingerprint()
        );
        return Ok(());
    }

    // Validate the request, authorize the account, and create the project.
    Ok(())
}

I like this placement because the app code stays honest about the boundary. Rototo is not deciding who the user is or whether they have permission. It is deciding whether this reviewed operational path is open right now.

Disable Through Review

If you need a global pause, change the selected default:

[resolve]
default = "disabled"

Run lint, open the smallest PR that explains the change, and merge it through the same path as a code change:

rototo lint operations-config
git add operations-config
git commit -m "Disable project creation during incident"

Long-running services that use RefreshingWorkspace keep serving the last successfully loaded workspace until a refresh succeeds. After the merge reaches the workspace source, a successful refresh affects future project creation checks. If a refresh fails, the service keeps the last known-good workspace active.

Rollback follows the same path: revert the change, or make a new reviewed change that sets the default back to enabled.

Scope The Switch

A global switch helps when the whole system is affected. More often, the incident has a boundary: one region, one account class, or one integration. I prefer adding that boundary to the workspace instead of scattering if checks through app code.

Restore the default to enabled, then create operations-config/qualifiers/eu-accounts.toml:

schema_version = 1
description = "Accounts operating in the European region"

[[predicate]]
attribute = "account.region"
op = "eq"
value = "eu"

Now update operations-config/variables/project-creation-enabled.toml so only that named condition selects the disabled value:

schema_version = 1

description = "Whether accounts can create new projects"
type = "bool"

[values]
enabled = true
disabled = false

[resolve]
default = "enabled"

[[resolve.rule]]
qualifier = "eu-accounts"
value = "disabled"

The rule says exactly what the incident response means: disable project creation for accounts in the European region; keep the default open.

Generate The Context Contract

The qualifier now reads account.region. That is the right moment to generate the context schema skeleton from the workspace:

rototo init operations-config --context

On this workspace, rototo writes operations-config/schemas/context.schema.json:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "additionalProperties": true,
  "properties": {
    "account": {
      "type": "object",
      "additionalProperties": true,
      "properties": {
        "region": { "type": "string" }
      }
    }
  }
}

The schema makes the app contract explicit. If the workspace reads account.region, the app must send context with that shape when it resolves the switch.

Lint the workspace:

rototo lint operations-config

Then resolve both paths:

rototo resolve operations-config \
  --variable project-creation-enabled \
  --context account.region=us
value key: enabled
value: true
rototo resolve operations-config \
  --variable project-creation-enabled \
  --context account.region=eu
value key: disabled
value: false

Now the app supplies a fact it already knows at the request boundary, and the workspace owns the reviewed decision about what that fact means operationally.

Keep The Boundary Clear

Operational switches fit rototo when the value should be reviewed, refreshed, and explainable. They do not fit every kind of runtime decision.

Reach for this pattern when the runtime decision is:

Keep these decisions somewhere else:

That is the split worth protecting. Rototo gives the app a reviewed operational answer. The app still owns identity, permissions, state changes, and the domain logic around the request.