rototo 0.1.0-alpha.6

Control plane for runtime configuration of your application.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
# Rototo Concepts

Rototo is built around one tension: behavioral configuration should get the engineering rigor we give code, while still being as easy to change as data. To bring in the rigor, Rototo organizes everything into a **configuration package** that moves through a lifecycle a lot like code does. The idea is to gather all the pieces of a configuration, lay them out in an opinionated folder structure, and release them together as one unit. Inside a package, Rototo gives you a few core building blocks you can use to model and validate a wide range of configuration.

Here's the one-line version of each concept:

- **Package**: the git-versioned boundary that gets released as a unit.
- **Variable**: the named value an application asks for. It can be a plain type (`string`, `int`, and so on) or a structured value from a `catalog`.
- **Rule**: a conditional value for a variable. Each rule says "when this condition holds, use this value."
- **Qualifier**: a reusable named condition you can use across many variables.
- **Catalog**: a named set of allowed values a variable can pick from. Handy for objects that follow a schema (LLM parameters, say).
- **Context**: the runtime facts the application hands in.
- **Schema**: validation for package structure, context, catalog entries, or selected values.
- **Lint**: the check that a package is structurally and semantically ready to release.


## Rototo Package

Let's build a package we'll use for the rest of this page. If you haven't installed the `rototo` CLI yet, start there:

```sh
cargo install rototo
```

Now create a package called `app-config`. This is where all the configuration your application needs will live.

```sh
rototo init app-config
```

That gives you a folder that looks like this:

```sh
$> tree app-config
app-config
├── rototo-package.toml
├── evaluation-contexts
├── qualifiers
├── variables
├── catalogs
├── lint
6 directories, 1 file
```

The `evaluation-contexts`, `qualifiers`, `variables`, `catalogs`, and `lint` folders hold Rototo's building blocks - we'll get to each one. The `rototo-package.toml` file is the package-level file, and right now it just says:

```toml
schema_version = 1
```

That file is what marks `app-config` as the root of a Rototo package, and `schema_version = 1` tells Rototo which format it's reading. You don't have to think about this file beyond making sure it exists - either empty or with the `schema_version` key.

## Variable

A variable is the named value your application code asks for at runtime. The app asks for `checkout-timeout`, `llm-model`, or `enable-new-onboarding`, and Rototo figures out which value to hand back for the current context.

A variable can be backed by a plain type - `bool`, `int`, `number`, `string`, or `list`. It can also pull a value from a catalog when the configuration is a structured object you want to reuse and validate as a named entry.

Here's a variable for the checkout timeout:

```toml
schema_version = 1
type = "int"

[resolve]
default = 2000
```

Right now it always resolves to 2000. That's already worth something: the value is named, typed, kept in the package, and reviewed outside the application binary.

We can check that it resolves the way we expect:

```sh
rototo resolve app-config \
  --variable checkout-timeout
```

The next step is to let the value depend on runtime context. Variables do that with rules. Each rule says: when these conditions match, use this value instead of the default. You can write those conditions inline, or point at a qualifier when the same condition needs to be reused across several variables.

## Rule

A rule is how a variable picks a value for a specific situation. From the application's side, the variable still has one name and one contract - but the package can say that some contexts should get a different value than the default.

Rules exist to keep conditional configuration out of your application code. Instead of writing branching logic like "enterprise accounts get a bigger limit" inside the service, the service passes the account facts as context and asks Rototo for the variable. The package owns the condition and the chosen value.

A variable starts with a default, and rules override that default when their conditions match:

```toml
schema_version = 1
type = "int"

[resolve]
default = 2000

[[resolve.rule]]
when = 'context.account.plan == "enterprise"'
value = 5000
```

When the application resolves this variable, Rototo checks the rules in order. The first one that matches wins. If none match, you get the default.

```sh
rototo resolve app-config \
  --variable checkout-timeout \
  --context account.plan=enterprise
```

That resolves to `5000`. With a different context - or no matching context - it's back to `2000`.

Rules can sit right next to the variable when the condition is local to that one decision. But once the same condition starts showing up in several variables, give it a name as a qualifier and have the rules point at that. It keeps the package easier to review: a reader can see that several variables lean on the same runtime condition, instead of re-reading the same predicate every time.

## Qualifier

A qualifier is a named runtime condition. It looks at facts from the application context and answers whether that condition is true for the current resolution.

Qualifiers exist because configuration decisions often share the same conditions. If several variables need to know whether an account is on an enterprise plan, that condition deserves one name and one definition - so the package can review and change it in one place instead of repeating the same predicate across many rules.

Here's a qualifier called `enterprise-account`:

```toml
schema_version = 1

when = 'context.account.plan == "enterprise"'
```

Now a variable rule can just point at it:

```toml
schema_version = 1
type = "int"

[resolve]
default = 2000

[[resolve.rule]]
when = 'env.qualifier["enterprise-account"]'
value = 5000
```

When Rototo resolves the variable, it evaluates the qualifier against the same context the application passed in.

```sh
rototo resolve app-config \
  --variable checkout-timeout \
  --context account.plan=enterprise
```

The rule matches because `enterprise-account` comes out true, so the selected value is `5000`.

Qualifiers can also build on other qualifiers, so the package can compose named conditions out of smaller named conditions while keeping the rules readable. The useful line to hold: qualifiers describe *when* a configuration choice applies, and variables describe *what* value the application gets.

## The expression language

The strings in `when` (and the `query` form used for catalog-backed variables) aren't some bespoke Rototo syntax. They're a subset of [CEL](https://cel.dev), the Common Expression Language. CEL is a small, well-specified, side-effect-free language built for exactly this job: evaluating a boolean (or a value) against a structured input, safely and predictably. Reusing it means the syntax is already documented and stable, and the evaluation holds no surprises - no loops, no assignment, no I/O.

Rototo evaluates these expressions and adds two things on top of plain CEL. First, three input variables are always in scope. `context` is the runtime facts the application passes in. `entry` is the catalog entry under consideration in a `query`. And `env` is everything Rototo itself provides - kept separate so that what the application supplies (`context`) stays visibly distinct from what the control plane supplies. Today `env` has two members: `env.qualifier["enterprise-account"]` reads another qualifier, and `env.now` is the evaluation timestamp, an RFC3339 string Rototo captures once per resolution. Second, a set of named functions that configuration conditions keep reaching for - things like `startsWith`, `matches`, `semver`, `cidr`, `bucket`, and the `timeBefore`/`timeBetween` family. So a `when` expression is ordinary CEL - `==`, `&&`, `in`, `has()`, indexing, comparisons - against those variables, plus those functions.

`env.now` reads the wall clock, so a condition that depends on it resolves differently as time passes. That's exactly right for a launch window meant to open on its own, but it does mean the same package version is no longer a pure function of the context you pass. When you need a resolution you can reproduce - in a test, a `diff`, or an audit - pass the evaluation time in `context` and compare against that path instead, so the timestamp is an input you control rather than the ambient clock.

Rototo deliberately sticks to a subset. The schema-aware lint looks at how each `context` path is used and confirms an evaluation context declares it with a matching type - so a condition that compares a string field as a number, or reads a field no context provides, gets caught before release instead of at runtime. Paths used as an IP (`cidr`) or a timestamp (the `time*` functions) have to be declared with the matching JSON Schema format, because Rototo checks those formats on the values too.

## Catalog

Plain variables are plenty for a timeout, a feature flag, or a string. But some configuration needs to be a structured object. An LLM configuration, for instance, isn't just a model name - it might include the model, gateway, prompt, token budget, and temperature, and those fields should be reviewed and validated together.

A catalog is a named set of allowed structured values. Each entry has a name, and each entry has to match the catalog schema. A catalog-backed variable then selects one of those entries by name.

For example, here's a catalog schema for LLM parameters:

```json
{
  "type": "object",
  "required": ["model", "gateway", "max_output_tokens", "temperature"],
  "properties": {
    "model": { "type": "string" },
    "gateway": { "type": "string" },
    "max_output_tokens": { "type": "integer", "minimum": 1 },
    "temperature": { "type": "number", "minimum": 0, "maximum": 2 }
  },
  "additionalProperties": false
}
```

Save that as:

```sh
catalogs/llm-parameters.schema.json
```

Then add catalog entries under a matching entries folder:

```sh
catalogs/llm-parameters-entries/standard.toml
catalogs/llm-parameters-entries/enterprise.toml
```

A `standard` entry might look like this:

```toml
model = "gpt-5-mini"
gateway = "openai"
max_output_tokens = 2400
temperature = 0.3
```

And an `enterprise` entry like this:

```toml
model = "gpt-5"
gateway = "openai"
max_output_tokens = 5000
temperature = 0.2
```

Now a variable can select from that catalog:

```toml
schema_version = 1
type = "catalog:llm-parameters"

[resolve]
default = "standard"

[[resolve.rule]]
when = 'env.qualifier["enterprise-account"]'
value = "enterprise"
```

From the application's point of view, this still behaves like any other variable. The app asks for the named variable, passes context, and gets the selected value back. The difference is that the value is a validated catalog entry, not a primitive literal.

This keeps structured configuration from getting scattered across a bunch of unrelated variables. When several fields have to change together, a catalog gives that combination a name and lets lint catch missing fields, wrong field types, and references to entries that don't exist.

## Catalog Query

Sometimes the application doesn't want one catalog entry - it wants a filtered list of them. A dropdown is the classic case: the package might define every supported LLM parameter set, but the app should only show the ones that are currently enabled.

Catalog queries handle that. A variable can resolve to `list<catalog:...>` and use a query to pick the matching entries.

First, add an `enabled` field to the `llm-parameters` catalog schema:

```json
{
  "type": "object",
  "required": ["enabled", "label", "model", "gateway", "max_output_tokens", "temperature"],
  "properties": {
    "enabled": { "type": "boolean" },
    "label": { "type": "string" },
    "model": { "type": "string" },
    "gateway": { "type": "string" },
    "max_output_tokens": { "type": "integer", "minimum": 1 },
    "temperature": { "type": "number", "minimum": 0, "maximum": 2 }
  },
  "additionalProperties": false
}
```

Then entries can say whether they're selectable:

```toml
enabled = true
label = "Fast model"
model = "gpt-5-mini"
gateway = "openai"
max_output_tokens = 2400
temperature = 0.3
```

Now define a variable that returns the enabled entries:

```toml
schema_version = 1
type = "list<catalog:llm-parameters>"

[resolve]
default = []

[[resolve.rule]]
query = "entry.enabled == true"
```

When the application resolves this variable, Rototo runs the query against each catalog entry and returns every entry that matches as part of the list.

That gives the application a reviewed, validated set of dropdown options without hardcoding the choices in the UI. Rototo owns which entries exist and which are enabled; the application owns how to render the list it gets back.

## Context

Context is the runtime data the application hands to Rototo when it asks for a variable. The package holds the configuration, but the application is the one that knows the facts about the current request, user, account, device, cart, or environment. Context is how those facts get into the resolution.

For example, this CLI input:

```sh
rototo resolve app-config \
  --variable checkout-timeout \
  --context account.plan=enterprise
```

is the same as resolving with this JSON context:

```json
{
  "account": {
    "plan": "enterprise"
  }
}
```

Rules and qualifiers read that context through `context.<path>` expressions:

```toml
when = 'context.account.plan == "enterprise"'
```

Context should have a contract. Without one, package authors can accidentally write rules against fields the application never sends, or compare a field as a string when the app actually sends a number. Rototo handles that with evaluation context schemas.

Create a schema at:

```
evaluation-contexts/request.schema.json
```

For the examples above, it might start like this:

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "account": {
      "type": "object",
      "additionalProperties": true,
      "properties": {
        "plan": { "type": "string" },
        "seats": { "type": "integer" }
      }
    },
    "user": {
      "type": "object",
      "additionalProperties": true,
      "properties": {
        "id": { "type": "string" },
        "tier": { "type": "string" }
      }
    }
  }
}
```

You can also keep sample contexts beside the schema:

```
evaluation-contexts/request-samples/enterprise.json
```

```json
{
  "account": {
    "plan": "enterprise",
    "seats": 250
  },
  "user": {
    "id": "user-123",
    "tier": "premium"
  }
}
```

Those samples are handy for local resolution, linting, review, and documentation. They make the runtime assumptions visible in the package instead of leaving them buried in application code.

Context isn't configuration. It's the input used to *choose* configuration. The package owns the rules, qualifiers, schemas, catalog entries, and variable values; the application owns the runtime facts it passes into resolution.

## Schema

Schemas are the foundation and the first line of defense in how Rototo validates a package. A package can hold a lot of files, but the values that matter still need contracts - whether that's an evaluation context or a catalog entry.

We've already used two kinds of schema.

The first is the evaluation context schema:

```text
evaluation-contexts/request.schema.json
```

This describes the runtime facts the application may pass into resolution. When a qualifier reads `context.account.plan`, the schema is where that path is declared and typed. That lets Rototo catch package mistakes before release - like a qualifier depending on `context.account.tier` when the app only ever sends `context.account.plan`.

The second is the catalog schema:

```
catalogs/llm-parameters.schema.json
```

This describes every entry in the `llm-parameters` catalog. If the schema says `max_output_tokens` must be an integer and `temperature` must sit between 0 and 2, every entry has to satisfy that contract.

For example, this entry is valid:

```toml
enabled = true
label = "Fast model"
model = "gpt-5-mini"
gateway = "openai"
max_output_tokens = 2400
temperature = 0.3
```

And this one should fail validation:

```toml
enabled = true
label = "Broken model"
model = "gpt-5-mini"
gateway = "openai"
max_output_tokens = "many"
temperature = 9
```

That failure matters because it happens while the package is being reviewed - not after the application has already loaded the configuration in production.

Schemas aren't the runtime API. Variables are still what applications resolve. Schemas sit behind variables, catalogs, and context to make sure the package is safe to release. Which brings us to lint: the package-level check that applies these contracts and tells you whether the whole package holds together.

## Lint

Lint is the release gate for a package. It checks whether the files are valid on their own, and whether they make sense together as one releasable unit.

Run it before you treat a package change as ready:

```sh
rototo lint app-config
```

Built-in lint covers the Rototo model: the package manifest, variables, rules, qualifiers, catalogs, catalog entries, evaluation context schemas, and the references between them. It catches things like a variable selecting a catalog entry that doesn't exist, a rule referencing an unknown qualifier, a catalog entry that fails its schema, or a qualifier reading a context path no evaluation context schema declares.

Rototo also supports custom lint for the policy that belongs to *your* package. Built-in lint validates Rototo semantics, but it can't know your operational rules. A team might decide, say, that enabled LLM parameter sets have to use a conservative temperature.

Custom lint lives under the package's `lint/` folder as Lua files:

```lua
function register(lint)
  lint:rule({
    id = "ai/llm-temperature-limit",
    title = "Enabled LLM temperature is too high",
    help = "Keep enabled LLM parameter sets at or below temperature 1.0.",
    target = "/catalogs/llm-parameters/entries",
    handler = "check_temperature",
  })
end


function check_temperature(package, entry)
  if entry.value.enabled == true and entry.value.temperature > 1.0 then
    return {
      {
        message = "enabled LLM parameter set must use temperature <= 1.0",
        path = "/value/temperature",
      }
    }
  end
  return {}
end
```

The custom rule id uses an authority your package or team owns, like `ai/...`. Rototo's built-in diagnostics use the reserved `rototo/...` authority.

For automation, lint can emit JSON:

```sh
rototo lint app-config --json
```

Lint is where the package model comes together. Variables define what applications ask for, rules and qualifiers define when values apply, catalogs hold reusable structured values, context schemas define runtime inputs, and lint checks that all of it forms a coherent package before release.

## Putting It Together

A Rototo package is the unit that gets reviewed and released. Inside it, variables define the values applications ask for, rules choose values for runtime situations, qualifiers give shared conditions a name, catalogs hold structured reusable values, context carries runtime facts from the application, schemas define the contracts, and lint checks that the whole thing is releasable.

At runtime, the application doesn't read individual TOML or JSON files. It loads a package source and resolves named variables with context:

```sh
rototo resolve app-config \
  --variable checkout-timeout \
  --context account.plan=enterprise
```

The same model works when the package comes from git instead of a local folder. The source changes, but the boundary stays the same: the application loads a reviewed package and asks Rototo for typed configuration values.

That's the core Rototo model. Configuration stays data, so it can be reviewed, validated, and released on its own schedule, apart from the application binary. But it still follows engineering discipline: clear ownership, explicit contracts, reproducible package state, and checks before release.