Production Workflow
The Adopt pages before this one define how I would run rototo in production: model the runtime decision, integrate through the SDK, test the app-workspace contract, and treat workspace changes as releases.
Here is that approach as one concrete path. We continue the account-config
workspace and account-app from getting started, then add the pieces I would
want before trusting it in a service: a named condition, a context contract, a
hosted workspace source, workspace policy lint, merge gates, app contract
tests, and runtime observability.
The core split does not change. The application is still deployed with a workspace source URI. The app still supplies runtime facts. The workspace still owns the policy for selecting the value.
Add The Runtime Condition
The first production gap is that account limits should vary by account facts.
Premium accounts should receive a larger max-active-projects value, but that
condition should not be hidden in app code.
Create account-config/qualifiers/premium-account.toml:
schema_version = 1
description = "Requests from premium accounts"
[[predicate]]
attribute = "account.plan"
op = "eq"
value = "premium"
Update account-config/variables/max-active-projects.toml:
schema_version = 1
description = "Maximum active projects for an account"
type = "int"
[values]
standard = 3
premium = 25
[resolve]
default = "standard"
[[resolve.rule]]
qualifier = "premium-account"
value = "premium"
The qualifier gives the condition a name. The variable rule can now say
premium-account -> premium, and the app does not need to know how premium is
defined.
Add The Context Contract
The workspace now depends on account.plan. That path is part of the
context contract between the app and workspace, so it
should be validated.
Generate a context schema skeleton:
rototo init account-config --context
For this workspace, that writes account-config/schemas/context.schema.json
with the context path used by the qualifier:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": true,
"properties": {
"account": {
"type": "object",
"additionalProperties": true,
"properties": {
"plan": { "type": "string" }
}
}
}
}
Review this file. The generator gives you a starting point; it does not know which fields should be required, which values are allowed, or which app boundary owns them.
Run lint:
rototo lint account-config
Then resolve both paths with the same facts the app will send:
rototo resolve account-config \
--variable max-active-projects \
--context account.plan=standard
rototo resolve account-config \
--variable max-active-projects \
--context account.plan=premium
The app should now build context from account facts instead of sending an empty object:
let account_plan =
std::env::var("ACCOUNT_PLAN").unwrap_or_else(|_| "standard".to_owned());
let context = ResolveContext::from_json(serde_json::json!({
"account": {
"plan": account_plan
}
}))?;
account_plan = os.environ.get("ACCOUNT_PLAN", "standard")
context = {
"account": {
"plan": account_plan,
},
}
const accountPlan = process.env.ACCOUNT_PLAN ?? "standard";
const context = {
account: {
plan: accountPlan,
},
};
String accountPlan = System.getenv().getOrDefault(
"ACCOUNT_PLAN",
"standard"
);
Map<String, Object> context = Map.of(
"account",
Map.of("plan", accountPlan)
);
accountPlan := os.Getenv("ACCOUNT_PLAN")
if accountPlan == "" {
accountPlan = "standard"
}
resolveContext := map[string]any{
"account": map[string]any{
"plan": accountPlan,
},
}
Run the app as a premium account:
ACCOUNT_PLAN=premium cargo run -- ../account-config
At this point the split is visible: the app supplies facts, and the workspace decides which configured value those facts select.
Publish The Workspace Source
A production service should load a
source it can fetch from its runtime
environment. Since git is the source of truth, publish account-config as a
private repository and pass the app a git workspace URI.
The following commands use the GitHub CLI and SSH. The runtime environment needs an SSH key or deploy key that can read the repository.
cd /path/to/account-config
git init .
git add .
git commit -m "Initialize account config workspace"
git branch -M main
GITHUB_OWNER="$(gh api user --jq .login)"
gh repo create "$GITHUB_OWNER/account-config" \
--private \
--source . \
--remote origin \
--push
export WORKSPACE_URI="git+ssh://git@github.com/${GITHUB_OWNER}/account-config.git#main"
Run the app with the hosted source:
cd /path/to/account-app
ACCOUNT_PLAN=premium cargo run -- "$WORKSPACE_URI"
The #main ref means refreshes can discover later reviewed commits on main.
Use a full commit SHA when a job or deployment needs exact reproducibility; that
source will not discover newer commits through refresh.
Add Workspace Policy Lint
Built-in lint protects rototo's structural contracts. The workspace also needs local policy: account project limits should be positive, stay under an operational ceiling, and keep the standard plan from accidentally exceeding the premium plan.
That policy belongs with the workspace because reviewers need to see the values and the guardrail together.
Create account-config/lint/max-active-projects.lua:
function register(lint)
lint:on({
stage = "policy",
entity = "variable",
rule = {
id = "operations/max-active-projects-policy",
title = "Account project limit violates operations policy",
help = "Keep max-active-projects values between 1 and 100 and keep standard <= premium.",
},
handler = "check_max_active_projects",
})
end
function check_max_active_projects(ctx)
if ctx.target.id ~= "max-active-projects" then
return {}
end
local values = ctx.target.toml.values or {}
local diagnostics = {}
for name, value in pairs(values) do
if type(value) ~= "number" or value < 1 or value > 100 then
table.insert(diagnostics, {
message = "max-active-projects." .. name .. " must be between 1 and 100"
})
end
end
if type(values.standard) == "number"
and type(values.premium) == "number"
and values.standard > values.premium then
table.insert(diagnostics, {
message = "max-active-projects.standard must not exceed max-active-projects.premium"
})
end
return diagnostics
end
Run lint again:
rototo lint account-config
The custom rule uses the operations/ authority. Built-in rototo rules stay
under the rototo/ authority, which keeps diagnostic ownership clear.
Put Gates Before Merge
The workspace repository should reject bad edits before they reach the branch that services refresh from. Use a local hook for fast feedback and CI for the shared gate.
Add .pre-commit-config.yaml to account-config:
repos:
- repo: local
hooks:
- id: rototo-lint
name: rototo lint
entry: rototo lint .
language: system
pass_filenames: false
Install the hook:
pre-commit install
Add .github/workflows/rototo.yml:
name: Rototo
on:
pull_request:
push:
branches:
- main
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- run: cargo install rototo --locked
- run: rototo lint .
Now the workspace has the same release discipline as code: edit, lint locally, open a PR, run CI, review the runtime behavior delta, and merge.
Test The App Contract
Workspace lint proves the workspace is valid. App tests prove the application can still consume the selected values and apply the policy.
Generate readable behavior fixtures:
rototo fixtures account-config \
--variable max-active-projects \
--qualifier premium-account \
--out account-app/tests/rototo-fixtures
Commit the generated tests/rototo-fixtures directory with the app tests. The
fixture diff should be part of review when runtime behavior intentionally
changes.
Add an app contract test in the app's test framework:
use std::error::Error;
use rototo::{ResolveContext, Workspace};
#[tokio::test]
async fn rototo_workspace_fixtures_still_hold() -> Result<(), Box<dyn Error>> {
let source = std::env::var("ROTOTO_WORKSPACE_SOURCE")
.unwrap_or_else(|_| "../account-config".to_owned());
let workspace = Workspace::load(source).await?;
let report =
rototo::testing::assert_fixtures(&workspace, "tests/rototo-fixtures").await?;
assert!(report.cases > 0);
Ok(())
}
#[tokio::test]
async fn max_active_projects_deserializes_for_app_contexts() -> Result<(), Box<dyn Error>> {
let source = std::env::var("ROTOTO_WORKSPACE_SOURCE")
.unwrap_or_else(|_| "../account-config".to_owned());
let workspace = Workspace::load(source).await?;
let standard = ResolveContext::from_json(serde_json::json!({
"account": { "plan": "standard" }
}))?;
let premium = ResolveContext::from_json(serde_json::json!({
"account": { "plan": "premium" }
}))?;
let standard = workspace
.resolve_variable("max-active-projects", &standard)
.await?;
let premium = workspace
.resolve_variable("max-active-projects", &premium)
.await?;
assert_eq!(standard.value_key, "standard");
assert_eq!(premium.value_key, "premium");
let standard: i64 = serde_json::from_value(standard.value)?;
let premium: i64 = serde_json::from_value(premium.value)?;
assert_eq!(standard, 3);
assert_eq!(premium, 25);
Ok(())
}
import os
import rototo
async def test_max_active_projects_deserializes_for_app_contexts():
source = os.environ.get("ROTOTO_WORKSPACE_SOURCE", "../account-config")
workspace = await rototo.Workspace.load(source)
standard = await workspace.resolve_variable(
"max-active-projects",
{"account": {"plan": "standard"}},
)
premium = await workspace.resolve_variable(
"max-active-projects",
{"account": {"plan": "premium"}},
)
assert standard.value_key == "standard"
assert premium.value_key == "premium"
assert standard.value == 3
assert premium.value == 25
import assert from "node:assert/strict";
import test from "node:test";
import { Workspace } from "rototo";
test("max-active-projects deserializes for app contexts", async () => {
const source = process.env.ROTOTO_WORKSPACE_SOURCE ?? "../account-config";
const workspace = await Workspace.load(source);
const standard = await workspace.resolveVariable(
"max-active-projects",
{ account: { plan: "standard" } },
);
const premium = await workspace.resolveVariable(
"max-active-projects",
{ account: { plan: "premium" } },
);
assert.equal(standard.valueKey, "standard");
assert.equal(premium.valueKey, "premium");
assert.equal(standard.value, 3);
assert.equal(premium.value, 25);
});
@Test
void maxActiveProjectsDeserializesForAppContexts() throws Exception {
String source = System.getenv().getOrDefault(
"ROTOTO_WORKSPACE_SOURCE",
"../account-config"
);
try (Workspace workspace = Workspace.load(source).get()) {
VariableResolution standard = workspace
.resolveVariable(
"max-active-projects",
Map.of("account", Map.of("plan", "standard"))
)
.get();
VariableResolution premium = workspace
.resolveVariable(
"max-active-projects",
Map.of("account", Map.of("plan", "premium"))
)
.get();
assertEquals("standard", standard.valueKey());
assertEquals("premium", premium.valueKey());
assertEquals(3L, ((Number) standard.value()).longValue());
assertEquals(25L, ((Number) premium.value()).longValue());
}
}
func TestMaxActiveProjectsDeserializesForAppContexts(t *testing.T) {
source := os.Getenv("ROTOTO_WORKSPACE_SOURCE")
if source == "" {
source = "../account-config"
}
ctx := context.Background()
workspace, err := rototo.Load(ctx, source, nil)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
standard, err := workspace.ResolveVariable(
ctx,
"max-active-projects",
map[string]any{"account": map[string]any{"plan": "standard"}},
nil,
)
if err != nil {
t.Fatal(err)
}
premium, err := workspace.ResolveVariable(
ctx,
"max-active-projects",
map[string]any{"account": map[string]any{"plan": "premium"}},
nil,
)
if err != nil {
t.Fatal(err)
}
if standard.ValueKey != "standard" || premium.ValueKey != "premium" {
t.Fatalf("unexpected value keys: %s, %s", standard.ValueKey, premium.ValueKey)
}
if standard.Value != float64(3) || premium.Value != float64(25) {
t.Fatalf("unexpected values: %v, %v", standard.Value, premium.Value)
}
}
Run the app tests against the local workspace:
cd /path/to/account-app
cargo test
In CI, set ROTOTO_WORKSPACE_SOURCE to the same git source URI the service
uses when the app repository should test against the hosted workspace.
Release And Observe
Before merging a workspace change, the pull request should say what behavior can change and how to recover:
Change max-active-projects:
- add premium-account rule
- standard accounts keep value key standard
- premium accounts select value key premium
- rototo lint and account-app contract tests passed
- rollback: revert this workspace commit
After merge, services following the branch source can refresh to the new workspace. The application binary does not redeploy, but future resolutions can change.
The service should log the selected value key and workspace fingerprint near the behavior boundary:
let resolution = workspace
.resolve_variable("max-active-projects", &context)
.await?;
tracing::info!(
variable = "max-active-projects",
value_key = %resolution.value_key,
workspace_fingerprint = ?workspace.current().await.source_fingerprint(),
account_plan = %account_plan,
"resolved runtime configuration"
);
resolution = await workspace.resolve_variable(
"max-active-projects",
context,
)
logger.info(
"resolved runtime configuration",
extra={
"variable": "max-active-projects",
"value_key": resolution.value_key,
"account_plan": account_plan,
},
)
const resolution = await workspace.resolveVariable(
"max-active-projects",
context,
);
logger.info("resolved runtime configuration", {
variable: "max-active-projects",
valueKey: resolution.valueKey,
accountPlan,
});
VariableResolution resolution = workspace
.resolveVariable("max-active-projects", context)
.get();
logger.info(
"resolved runtime configuration variable={} valueKey={} accountPlan={}",
"max-active-projects",
resolution.valueKey(),
accountPlan
);
resolution, err := workspace.ResolveVariable(
ctx,
"max-active-projects",
resolveContext,
nil,
)
if err != nil {
return err
}
slog.Info(
"resolved runtime configuration",
"variable", "max-active-projects",
"value_key", resolution.ValueKey,
"account_plan", accountPlan,
)
It should also expose refresh status:
let status = workspace.status().await;
if status.consecutive_failures > 0 {
tracing::warn!(
consecutive_failures = status.consecutive_failures,
last_error = ?status.last_error,
"workspace refresh is failing; serving last-known-good configuration"
);
}
status = await workspace.status()
if status.consecutive_failures > 0:
logger.warning(
"workspace refresh is failing; serving last-known-good configuration",
extra={
"consecutive_failures": status.consecutive_failures,
"last_error": status.last_error,
},
)
const status = await workspace.status();
if (status.consecutiveFailures > 0) {
logger.warn(
"workspace refresh is failing; serving last-known-good configuration",
{
consecutiveFailures: status.consecutiveFailures,
lastError: status.lastError,
},
);
}
RefreshStatus status = workspace.status().get();
if (status.consecutiveFailures() > 0) {
logger.warn(
"workspace refresh is failing; serving last-known-good configuration " +
"consecutiveFailures={} lastError={}",
status.consecutiveFailures(),
status.lastError()
);
}
status, err := workspace.Status(ctx)
if err != nil {
return err
}
if status.ConsecutiveFailures > 0 {
slog.Warn(
"workspace refresh is failing; serving last-known-good configuration",
"consecutive_failures", status.ConsecutiveFailures,
"last_error", status.LastError,
)
}
If the policy is wrong, revert the workspace commit. If the app sent the wrong context or cannot consume the selected value, fix the app-workspace contract and redeploy the app.
What This Workflow Gives You
The final system has one clear path:
- The app is deployed with a workspace source URI.
- The SDK loads and lints that source at startup.
- The app supplies runtime facts as context.
- The workspace evaluates named conditions and variables.
- Tests prove the workspace and app still agree.
- Refresh lets reviewed workspace commits affect future resolutions.
- Last-known-good state protects running services from failed refreshes.
- Logs and refresh status explain what value was selected and from which workspace version.
That is the production goal: runtime configuration can move independently from the application binary, while still going through review, validation, tests, observability, and git-backed recovery.