Bucketed Rollout
Some changes should not reach every account at once. A new search ranking mode may be ready for production traffic, but I still want a narrow, stable rollout: test accounts first, then a small percentage of real accounts, then a wider range after the team has observed the behavior.
The important word is stable. The same account should get the same result on every request while the rollout policy is unchanged. Rototo bucket predicates give us that behavior without putting random selection in app code.
We will model that as rollout-config, with one variable named
search-ranking-mode.
Start With The Stable Mode
Create the workspace:
rototo init rollout-config --variable search-ranking-mode
Replace rollout-config/variables/search-ranking-mode.toml:
schema_version = 1
description = "Search ranking mode used for catalog queries"
type = "string"
[values]
stable = "stable"
hybrid = "hybrid"
[resolve]
default = "stable"
The app can support both modes, but the workspace still selects stable for
everyone. That is the starting point I want before adding rollout policy.
Lint and resolve the default:
rototo lint rollout-config
rototo resolve rollout-config --variable search-ranking-mode
value key: stable
value: "stable"
Enable Test Accounts First
Before sending traffic to a percentage bucket, I want an explicit live test path. Test accounts exercise the same SDK call and the same production workspace source as regular accounts, but they do not change the regular account experience.
Create rollout-config/qualifiers/test-accounts.toml:
schema_version = 1
description = "Accounts marked for live configuration testing"
[[predicate]]
attribute = "account.kind"
op = "eq"
value = "test"
Update rollout-config/variables/search-ranking-mode.toml:
schema_version = 1
description = "Search ranking mode used for catalog queries"
type = "string"
[values]
stable = "stable"
hybrid = "hybrid"
[resolve]
default = "stable"
[[resolve.rule]]
qualifier = "test-accounts"
value = "hybrid"
This is the first PR I would ship. The service can refresh the workspace, and
test accounts can use hybrid while every regular account stays on stable.
Generate the first context schema:
rototo init rollout-config --context
On this workspace, rototo writes rollout-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": {
"kind": { "type": "string" }
}
}
}
}
Lint the workspace:
rototo lint rollout-config
Resolve both paths:
rototo resolve rollout-config \
--variable search-ranking-mode \
--context account.kind=regular
value key: stable
rototo resolve rollout-config \
--variable search-ranking-mode \
--context account.kind=test
value key: hybrid
Add A Stable Bucket
After the test-account path looks good, add a bucket for a small slice of real accounts.
Create rollout-config/qualifiers/hybrid-ranking-bucket.toml:
schema_version = 1
description = "Stable five percent rollout bucket for hybrid ranking"
[[predicate]]
attribute = "account.id"
op = "bucket"
salt = "search-ranking-hybrid-2026-06"
range = [0, 500]
The bucket range is on a 0 to 10000 scale, so [0, 500] is five percent. The
salt names this rollout. Keep it stable while you widen the range; changing the
salt reshuffles account assignment.
Now update the variable:
schema_version = 1
description = "Search ranking mode used for catalog queries"
type = "string"
[values]
stable = "stable"
hybrid = "hybrid"
[resolve]
default = "stable"
[[resolve.rule]]
qualifier = "test-accounts"
value = "hybrid"
[[resolve.rule]]
qualifier = "hybrid-ranking-bucket"
value = "hybrid"
Rules are evaluated in order. Test accounts stay first because they are an explicit operator-controlled path. The bucket covers regular accounts after that.
Regenerate The Context Contract
The new bucket qualifier introduced account.id. Regenerate the context schema
after that path exists. Since the file already exists from the test-account
phase, use --force and review the resulting diff:
rototo init rollout-config --context --force
On this workspace, the regenerated
rollout-config/schemas/context.schema.json includes both paths:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": true,
"properties": {
"account": {
"type": "object",
"additionalProperties": true,
"properties": {
"id": { "type": ["boolean", "number", "string"] },
"kind": { "type": "string" }
}
}
}
}
Lint the workspace:
rototo lint rollout-config
Resolve Stable Assignments
The account ID is the bucket input. The same account ID and salt produce the same bucket value every time.
acct-0001 is outside this five percent range:
rototo resolve rollout-config \
--variable search-ranking-mode \
--context account.kind=regular \
--context account.id=acct-0001
test: bucket salt=search-ranking-hybrid-2026-06 range=[0,500] bucket=2978
value key: stable
acct-0005 is inside the range:
rototo resolve rollout-config \
--variable search-ranking-mode \
--context account.kind=regular \
--context account.id=acct-0005
test: bucket salt=search-ranking-hybrid-2026-06 range=[0,500] bucket=134
value key: hybrid
The bucket value is deterministic, not sampled per request. That means logs,
support investigations, and app tests can explain why an account received
hybrid.
Widen Through Review
When the five percent rollout looks healthy, widen the same bucket by changing the range:
[[predicate]]
attribute = "account.id"
op = "bucket"
salt = "search-ranking-hybrid-2026-06"
range = [0, 2000]
That moves the rollout to twenty percent without changing the salt. Existing
accounts that were already inside [0, 500] remain inside the wider range.
Run lint, review the diff, and merge through the same release path:
rototo lint rollout-config
Rollback is the reverse: move the range back to the previous value, or remove the bucket rule while leaving the test-account path in place.
Use The Mode In The App
The app should resolve the mode near the code path that needs it. Rototo selects the reviewed rollout policy; the app owns both ranking implementations and the metrics that compare them.
use rototo::{ResolveContext, Workspace};
async fn search_ranking_mode(
workspace: &Workspace,
account_kind: &str,
account_id: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let context = ResolveContext::from_json(serde_json::json!({
"account": {
"kind": account_kind,
"id": account_id
}
}))?;
let resolution = workspace
.resolve_variable("search-ranking-mode", &context)
.await?;
let value_key = resolution.value_key.clone();
let mode: String = serde_json::from_value(resolution.value)?;
println!(
"selected search-ranking-mode `{}` from {:?}",
value_key,
workspace.source_fingerprint()
);
Ok(mode)
}
Keep the implementation boundary clear. Rototo should not choose search results, store account history, or own ranking metrics. It should answer which reviewed mode applies for this request.
Keep The Rollout Explainable
Use bucketed rollout when the assignment should be:
- deterministic for the same context value;
- controlled through review;
- observable in logs and traces;
- reversible without an app redeploy.
Avoid it when the decision belongs to another runtime system:
- per-request random sampling;
- model scoring;
- account records;
- metrics collection;
- high-volume mutable state.
The workspace owns the rollout policy. The app owns the behavior behind each mode and the evidence that tells the team whether to widen, hold, or roll back.