rototo
DocsAdopt
Adopt

Modeling Runtime Configuration

Runtime configuration becomes hard to operate when the model hides where a decision is made. The app sends a few facts, another system turns those facts into booleans, a config file holds a few values, and six months later nobody can explain why one account received one behavior and another account did not.

Rototo puts that decision in one place. The modeling question is not "which TOML files do I need?" It is:

Where should this runtime decision live?

In rototo, facts live in context. Named conditions live in qualifiers. Selected configuration lives in variables. Structured payloads live in resources. Schemas and custom lint protect the boundaries. Workspaces and layers define who owns which part of the control plane.

The rest of this guide is about choosing those boundaries deliberately.

Start With The App Boundary

Start with the question the application needs to ask at runtime.

For an account limit policy, the app probably does not want to ask four separate questions:

max-projects?
max-members?
audit-retention-days?
enabled-features?

Those fields may all change together. They may be reviewed together. The app may need them as one account profile. If so, the rototo variable should model that atomic decision:

account-limit-profile

The app now has one stable call:

let limits = workspace
    .resolve_variable("account-limit-profile", &context)
    .await?;

The app asks for the policy it needs. Rototo selects the value. The app does not reconstruct policy by resolving a pile of loosely related variables.

Splitting variables is still right when the app can change, test, observe, or fail the decisions independently. What matters is that the split follows the application boundary, not the number of fields in a payload.

Treat The Workspace As An Administrative Boundary

A workspace is an administrative boundary, not an application deployment boundary.

That distinction matters. A workspace answers:

An application deployment answers a different question: which binary is running, and which workspace source URI is that binary configured to load?

Those boundaries often overlap, but they are not the same. A single workspace can be loaded by multiple application deployments. A single application deployment can load a layered workspace assembled from multiple administrative owners. A workspace change can affect future resolutions in a running service without redeploying the binary.

I would usually model these as stronger workspace boundaries:

product-defaults
customer-acme-config
acme-support-team-config
payments-runtime-policy

And I would be more cautious with boundaries like:

frontend-prod
backend-prod
service-a-config

Service-specific workspaces are not wrong. Sometimes one service really owns a policy end to end. But the first question should be ownership and policy, not deployment topology.

Layering makes this concrete:

product-defaults
  -> customer-acme-config
      -> acme-support-team-config

The app may load acme-support-team-config. Rototo still preserves the administrative story: product owns the schema and defaults, the customer owns account-wide policy, and the support team owns a narrower override.

Put Facts In Context, Policy In The Workspace

The runtime context should describe facts the app already knows:

{
  "account": {
    "id": "acct_123",
    "plan": "enterprise",
    "seats": 120
  },
  "request": {
    "country": "DE"
  }
}

The context should not contain the decision rototo is supposed to make:

{
  "use_enterprise_limits": true
}

That boolean may feel convenient, but it moves policy out of the workspace. Rototo can no longer explain why enterprise limits applied. Reviewers cannot inspect the condition. A future operator sees the selected value, but the reason already happened somewhere else.

In rototo, the app supplies facts:

{
  "account": {
    "plan": "enterprise",
    "seats": 120
  }
}

The workspace owns the policy:

[[predicate]]
attribute = "account.plan"
op = "eq"
value = "enterprise"

That is the split I want. The application owns what happened in this request. The workspace owns what that fact means for runtime behavior.

Use Qualifiers To Name Operational Conditions

Qualifiers are not just reusable predicates. They are the vocabulary that shows up in rules, traces, tests, and debugging conversations.

For example:

# qualifiers/enterprise-account.toml
schema_version = 1

[[predicate]]
attribute = "account.plan"
op = "eq"
value = "enterprise"

Now a variable rule can say what it means:

[[resolve.rule]]
qualifier = "enterprise-account"
value = "enterprise"

And a trace can explain the selection in the same language:

rule[0] if enterprise-account -> enterprise (matched)

Create a qualifier when the condition explains why behavior changes. Compose qualifiers when the composed name carries meaning:

[[predicate]]
attribute = "qualifier.enterprise-account"
op = "eq"
value = true

[[predicate]]
attribute = "account.seats"
op = "gte"
value = 100

That could be named large-enterprise-account.

Avoid chains where a reader has to open five files to understand one rule. A qualifier should reduce cognitive load. If the name no longer helps explain the decision, the model is probably too indirect.

Choose Primitive Values Or Resources By Contract Shape

Primitive values are right when the selected configuration is truly one scalar or one list:

schema_version = 1
type = "int"

[values]
standard = 3
expanded = 25

[resolve]
default = "standard"

Resources are the better fit when the selected value is a policy object:

account-limit-profile
notification-delivery-policy
inference-routing-policy
service-degradation-policy

For account limits, a resource-backed variable can select one validated object:

# variables/account-limit-profile.toml
schema_version = 1
type = "resource:account-limit-profile"

[resolve]
default = "growth"

[[resolve.rule]]
qualifier = "enterprise-account"
value = "enterprise"

The object can carry the whole profile:

# resources/account-limit-profile-objects/enterprise.toml
enabled_features = ["audit-log", "priority-support"]

[limits]
projects = 100
members = 250
monthly_requests = 1000000

The resource schema validates the selected object before the app consumes it. That is the practical reason to use resources: the workspace can prove the policy object has the shape the app expects.

Without that, shape errors move back into application code. The app becomes the first place to discover that a field is missing or a value has the wrong type.

Treat Defaults As The Baseline Policy

Defaults are not filler. The default value is the policy for everyone who does not match a named condition.

In a healthy variable, the default is normal behavior and rules are exceptions:

[resolve]
default = "growth"

[[resolve.rule]]
qualifier = "enterprise-account"
value = "enterprise"

[[resolve.rule]]
qualifier = "free-account"
value = "starter"

Rules use first-match semantics. Put narrower or higher-priority rules before broader rules.

Two patterns are worth treating as model smells:

Rototo reports both. They may not break runtime behavior, but they make policy harder to read. A reviewer should be able to tell which condition changes the selected value and why it wins.

Model Buckets Deliberately

Buckets help because assignment happens inside the reviewed workspace, not in application-side randomization.

A bucket qualifier looks like this:

schema_version = 1

[[predicate]]
attribute = "account.id"
op = "bucket"
salt = "account-limit-profile-2026-06"
range = [0, 1000]

The context attribute should be stable. Account id, user id, or workspace id are common choices. Request ids are usually wrong because they change every request.

The range controls how much of the bucket space matches. A range of [0, 1000] matches ten percent of the 0..10000 space.

The salt defines the assignment universe. Changing the range changes the percentage while preserving assignments for existing buckets. Changing the salt reshuffles assignments.

That makes salt changes operationally significant. Use them when you mean to reshuffle, not as an incidental rename.

Decide Which Workspace Owns The File

In a layered workspace, ownership is part of the model.

A common shape is:

product-defaults
  schemas/account-limit-profile.schema.json
  resources/account-limit-profile.toml
  variables/account-limit-profile.toml

customer-acme-config
  resources/account-limit-profile-objects/acme_default.toml
  variables/account-limit-profile.toml

acme-support-team-config
  qualifiers/support-pilot-account.toml
  resources/account-limit-profile-objects/support_pilot.toml
  variables/account-limit-profile.toml

The product layer owns the contract. The customer layer owns the customer-wide default. The support team layer owns a narrow override.

Remember that layered replacement is file-level. If a child layer writes variables/account-limit-profile.toml, it replaces the inherited file at that path. It is not patching individual TOML fields.

I want that because ownership is visible in the diff. It also means teams should keep variable files readable and intentional. A child layer that replaces a variable owns the full rule order for that variable.

Use Schemas For Shape And Lint For Judgment

Schemas and custom lint protect different kinds of mistakes.

Use schemas for structure:

Use custom lint for judgment:

The distinction I rely on is:

Use schemas for shape. Use custom lint for judgment.

That keeps structural contracts close to the values they validate, and keeps local policy explicit without forcing it into JSON Schema contortions.

Modeling Checklist

When I am deciding whether a rototo model is ready to grow, I use this checklist:

If those answers are clear, the production workflow becomes much easier. The next step is to wire the model into an application so the service loads a workspace source, resolves named variables, refreshes safely, and reports what it selected.