Package Format
A rototo package is just a folder of files. There's no database, no hidden state, no generated blob you have to keep in sync - what you see in the folder is the whole thing. That's on purpose: config you can read, diff, and review in a pull request is config you can trust.
This page is the tour of that folder. We'll go file by file, and for each one
I'll show you a real example pulled straight from the examples/basic package
that ships with rototo.
The shape of the folder
Here's the whole layout. Don't worry about memorizing it - rototo init makes
all of this for you. This is just so you know what goes where:
my-package/
├── rototo-package.toml # the manifest - marks this folder as a package
├── governance.toml # what overlays may change, when this is a base
├── variables/ # the values your app reads
│ ├── premium_users.toml
│ └── checkout_redesign.toml
├── lists/ # named closed sets of scalar values
│ └── plan_tiers.toml
├── layers/ # shared bucket lines for rollouts and experiments
│ └── checkout.toml
├── model/ # contracts: what values must look like
│ ├── catalogs/
│ │ └── checkout_redesign.schema.json
│ └── context/ # the shape of the facts your app passes in
│ ├── request.schema.json
│ └── request-samples/
│ └── premium_enterprise.json
├── data/ # values that satisfy those contracts
│ └── catalogs/
│ └── checkout_redesign/
│ └── control.toml
└── lint/ # your own custom checks, in Lua
└── checkout_redesign.lua
Notice the split between model/ and data/. model/ holds contracts: catalog
schemas and evaluation-context schemas. data/ holds the values that have to
satisfy those contracts: catalog entries. That separation matters in review: a
change under model/ changes what's allowed, a change under data/ changes
what's there. Variables, lists, and lint sit at the top level because each is
its own thing - a variable or a list is both contract and value in one file,
and lint is code. governance.toml appears only when the package is a base
that other packages extend.
Two rules tie the folder together.
First, the file name is the id. A file at variables/checkout_redesign.toml
defines a variable whose id is checkout_redesign. A catalog schema at
model/catalogs/checkout_redesign.schema.json defines a catalog whose id is
checkout_redesign. You never write the id inside the file - the filename
already said it. Subdirectories are namespaces, for every collection alike:
variables/acme/in_trial.toml is the variable acme/in_trial (referenced as
variables["acme/in_trial"] in expressions), lists/acme/tier.toml is
the list acme/tier, model/catalogs/acme/plans.schema.json is the catalog
acme/plans with its entries under data/catalogs/acme/plans/, and the same
holds for evaluation contexts and layers. Catalog entry ids themselves stay
flat: an entry is always a direct child of its catalog's data directory.
A file that sits under a rototo-owned directory but maps to no entity - a
mistyped suffix, an entry directory for a catalog with no schema - draws a
lint warning, rototo/unrecognized-file, instead of being silently ignored.
Second, ids are snake_case. Every id rototo recognizes - variables, lists,
catalogs, catalog entries, evaluation contexts, samples - is lowercase letters,
digits, and underscores, with / allowed for namespacing (like
payments/retry_limit). The reason is that ids show up in TOML table headers
and in expressions, where a hyphen is the minus operator:
variables.premium-users would parse as a subtraction. Snake_case keeps
variables.premium_users working everywhere. Lint enforces this as an error,
rototo/id-not-snake-case. (Diagnostic rule names like
rototo/id-not-snake-case itself are a separate namespace and stay
hyphenated.)
The manifest: rototo-package.toml
This is the file that says "this folder is a rototo package." It lives at the root, and it's deliberately tiny. The only thing it must have is a version marker:
schema_version = 1
That's a complete, valid manifest. The schema_version has to be exactly 1 -
it's how rototo knows it's reading a format it understands.
There are two optional things you can add.
The first is extends, for when this package builds on top of others - shared
defaults, a common set of condition variables, that kind of thing. You list the
parent packages as package sources:
schema_version = 1
extends = ["../shared-config", "git+https://github.com/acme/base-config.git#main"]
Each entry follows the exact same source grammar you'd type on the command line.
Relative paths are resolved against this package, so a package and its parents
can travel together. The full rules for what an extends entry may point at -
including why a remotely staged package can't reach into your filesystem, and
the depth and cycle limits - are in the package sources
reference. (When you build a distributable archive with rototo package, the extends array gets flattened in and stripped out - the archive is
already self-contained, so there's nothing left to point at.) How the layers
actually combine - which files replace and which compose - gets its own
section, Extending: how packages combine,
right after this one.
The second is [[trace]], which turns on resolution tracing for specific cases
without redeploying your app. You can have as many as you like:
[[trace]]
when = 'env.resolving.variable == "checkout_redesign" && context.user.id == "user-123"'
The when is an expression - same language as everywhere
else. We cover what tracing is for in Using Rototo; here, just
know it's a manifest thing.
Extending: how packages combine
When a package extends others, rototo flattens them into one file tree before
anything else happens - lint, resolution, rototo package, all of it sees the
flattened result. Parents flatten in extends order, the child comes last.
Flattening is deterministic: the packages are processed in a sorted, fixed order,
so the same inputs always produce the same flattened package.
One premise before the mechanics: modifying a base is permission-gated.
Adding new ids is always free, but every update or delete of something a
base declared needs that base to grant it in governance.toml - deny by
default is unconditional, and a base without the file grants nothing. The
composition rules below describe what the operations do; the
governance section describes who may perform
them. A base whose overlays are its own team typically opens itself with one
broad [defaults] grant.
The composition rules below describe the child landing on its bases - the
overlay relationship, where one package was authored to change another. Two
bases in the same extends array are a different relationship: siblings.
Neither was authored as an overlay of the other, so nothing about them may
silently merge. Each base is flattened on its own first, then the results
union: the composed package has every variable, catalog, list, evaluation
context, and layer from every base, and the bases have to be disjoint. Two
bases touching the same entity - the same variable id, the same list, context,
or layer - fails the load ("package extends bases conflict on ..."). Catalogs
and evaluation context samples are the places siblings may share: two bases
can each add their own entries to a catalog they both inherit from a common
ancestor, or their own sample files for a shared context, because those files
compose additively. The disjointness line just moves down a level - two bases
providing the same entry, one base updating or deleting an entry another base
provides, or two bases carrying different versions of the catalog schema, all
still fail the load. If two bases genuinely share more than that, make one
extend the other or move the shared piece into one package. Diamond ancestry
stays fine everywhere: two bases extending a common ancestor both carry its
files, and byte-identical restatements of the same file never conflict.
Governance stays per-base: each base's governance.toml governs its own
entities against the overlays that land after it, so the extending package
needs each base's grants for what it changes in that base, and a base with no
governance.toml stays ungoverned as usual.
The default rule is the simple one: a file replaces the file at the same path
in the base packages, whole. That's still what happens for model/ schemas
(catalog, list, and context), layers/, and lint/ files. But
four file shapes compose structurally instead, because whole-file replacement
can't say the things an overlay needs to say: it can't disable one base catalog
entry, can't change one field of an entry, can't add one list member, and it
forces you to copy an entire variable file just to change its resolution.
One bookkeeping note: while flattening, rototo records which package owns each
variable's [resolve] block in a small provenance sidecar, and resolution
traces read it back as the trace's provenance field. You never edit that
file; it's how rototo resolve can print resolve from <source> for a
composed package.
Why the merge rules are shaped this way
Every composition system ends up answering the same four questions, and the answers here are deliberate, so they are worth stating before the file shapes.
Arrays have no merge. The classic failure in overlay systems is partial
updates inside arrays: which element does the overlay mean? Kubernetes grew
per-field merge-key annotations to answer that, and the machinery is famously
heavy. Rototo avoids the question structurally. Everything that would be a
keyed array elsewhere - catalog entries, samples, variables - is a file in a
directory, so "merge by key" is just the filesystem: the key is the filename.
The one real ordered array left - a variable's rules - is positional, and
splicing half of one package's rules into another's produces a resolution
nobody wrote or reviewed. So rules never merge: an update's [resolve]
block replaces the base's whole.
Inside one entry, fields merge; arrays replace. A catalog entry's fields are independent facts, so an update names only the fields it changes and inherits the rest, recursing through nested tables. Arrays replace wholesale, never concatenate: merging two arrays invents an order and a membership nobody stated.
Deletion is a file, not a value. Formats that delete with an in-band
marker (JSON Merge Patch's null) lose the ability to ever set that value
legitimately. A deleted marker is its own file, visible in the diff by its
path alone, and it must name something real - an orphan marker fails the
load.
Order means authorship. An overlay chain is ordered because "I extend
you" is a statement of intent, so the layer above wins. Sibling bases in one
extends array carry no such statement, so between them there is no
priority: agreement (byte-identical files) composes, disagreement fails the
load. That is also why byte-identical restatement is always a no-op - it is
what shared ancestry looks like, not an edit.
The permission question - who may perform any of this - is governance's, covered below, and it denies by default.
Variables update through a marker
An overlay never restates a base variable's file. It updates one with a marker, the same shape catalog entries use:
variables/<id>.update.tomlupdates the base variable. The marker may carry only[resolve]anddescription- the fields an overlay is allowed to change. Each key it declares replaces the base's key whole; there is no key-level merge inside[resolve], the topmost package's resolve block wins whole. The marker itself never lands in the flattened package.- A plain
variables/<id>.tomlis always an add. If the base already declares the id, the load fails and points at the marker: "variable<id>is declared in the base packages; update it withvariables/<id>.update.tomlinstead of restating the file". That keeps a reviewer able to tell an add from an update by the file name alone.
The marker may not carry type or schema_version, even restated with the
base's exact values. The type is the contract applications were written
against; repeating it in an overlay would force every reviewer to check it
still agrees, and would silently pin the base's intent. An update file
carries only what it changes.
There is no variables/<id>.deleted.toml. An overlay never removes a base
variable - every consumer resolving the id would break - it can only give
the id different behavior. Removing a variable is the base's decision.
An orphan marker (no base variable to update) fails the load, and one package
providing both <id>.toml and <id>.update.toml is contradicting itself and
fails too. The one exception to the no-restatement rule is a byte-identical
copy of the base's file, which composes as a no-op - that is how diamond
ancestry looks when two bases share an ancestor.
An overlay can also add variables of its own, and subdirectories keep them out
of the base's way: variables/acme/in_trial.toml defines the namespaced
variable acme/in_trial, referenced as variables["acme/in_trial"].
Catalog entries: union, deletes, and updates
Catalog entries compose as a set. The active entries of a catalog are the entries the base packages provide, plus the entries this package provides, minus the entries this package deletes, with field updates applied. Two file shapes drive the minus and the update, both keyed by path next to the entries they act on:
data/catalogs/<catalog>/<entry>.deleted.tomlremoves the entry a base package provided from this package's view. The base file is untouched; the marker file itself never appears in the flattened package. By convention it containsdeleted = trueand an optionalreason = "...".data/catalogs/<catalog>/<entry>.update.tomlupdates fields of the entry below: tables merge recursively, scalars and arrays replace, and any field the update doesn't mention is inherited.
Both shapes have to point at something real. A deleted marker with no entry in
the base packages fails the load ("deleted marker has no catalog entry to
remove in the base packages"), and an orphaned update marker fails the same way. A
single package that both provides <entry>.toml and deletes or updates that
same entry also fails the load - it's contradicting itself. So does carrying
both an update and a deleted marker for one entry: updating and removing it
are contradictory.
Deleting an entry someone depends on is deliberately loud. If a base variable
still names the deleted entry, lint catches it as
rototo/variable-unknown-value, and the overlay has to override that
variable's resolution too. Removing data quietly out from under a variable is
exactly the drift this is designed to surface.
List members: union and delete
An overlay adjusts a base list's members through an update marker,
lists/<id>.update.toml, the same shape variables use. members declares
what the package adds, and deleted names the base members it removes:
# overlay's lists/plan_tiers.update.toml
members = ["acme_enterprise"]
deleted = ["legacy_bronze"]
The composed list has the base's members plus acme_enterprise, minus
legacy_bronze. The marker is consumed during flattening and never appears
in the flattened package. A marker may carry only members, deleted, and
description: the list's type is the contract half and is never updatable
from above, and restating lists/<id>.toml wholesale is rejected toward the
marker.
Deletes follow the same rules as catalog entry deletes. Every deleted value
has to name a member some base package actually provides ("deleted list member
is not in the base packages" fails the load), a single package may not both add
and delete the same value, and deleting every member fails the load - an
empty list is not a thing. In a package with no extends, a deleted key has
nothing to remove from and lint flags it (rototo/list-shape).
Deleting a member someone depends on is loud in the same way entry deletes are: if a base variable default, rule value, catalog entry, or context sample still uses the deleted member, lint on the flattened package catches it, and the overlay has to override that usage too.
Governance: governance.toml
extends composition lets an overlay change things. For one team splitting a
package across files, that's the point. For a tenant overlay, it's exactly
wrong: the app ships a contract, and a tenant should only move within it.
Governance is the file that makes layering safe - a dial on every capability
that each successive overlay can only turn further down.
The file is a single governance.toml at the package root. It holds one block
per governed entity, keyed [<kind>.<id>], where kind is one of catalog,
list, variable, evaluation_context, or layer:
[catalog.plans]
allowed_operations = ["add", "update", "delete"]
[catalog.plans.update_policy]
allowed_fields = ["monthly_price", "limits"]
denied_entries = ["free"]
[catalog.plans.delete_policy]
allowed_entries = ["*"]
denied_entries = ["free"]
[variable.active_plan]
allowed_operations = ["update"]
Read that as a contract: the overlay may add plan entries, may update
monthly_price and limits on any plan except free, may delete any plan
except free, and may update active_plan's resolution. Everything else it
might try on a base-declared entity is denied.
Deny by default, unconditionally
Governance denies by default whether or not the file exists. A base with no
governance.toml grants nothing: an overlay can add new ids next to it, but
any update or delete of something the base declared fails the load with
governance denies <op> on <kind>.<id>. The file is not a switch that turns
governance on - it is simply where the grants live, and no file means no
grants.
For a base whose overlays are its own team, spelling out every grant would be
noise. That is what the top-level [defaults] block is for:
[defaults]
allowed_operations = ["add", "update", "delete"]
[defaults] grants across every base-declared entity that a per-entity block
doesn't speak for itself. Per-entity blocks refine below it, and deny wins
from either level: a [defaults] grant of delete plus a
[catalog.plans] denied_operations = ["delete"] means everything but plans
entries can be deleted. [defaults] can carry update_policy and
delete_policy tables too, which apply where the entity's own block has
none.
The contract governs what the base declared, nothing more. New ids mint
freely: a tenant's own namespaced variables, its own catalogs and lists, its
own layers. Whether those minted ids are well named is a lint concern
(rototo/id-not-snake-case and friends), not a permission.
The three operations
Each operation names one on-disk shape the overlay can produce:
| Operation | What the overlay does on disk |
|---|---|
add | a new <entry>.toml in a governed catalog |
update | an <entry>.update.toml over a base catalog entry, a lists/<id>.update.toml that adjusts a base list's members, a variables/<id>.update.toml over a base variable, or a replacement of a base layer file under layers/ |
delete | an <entry>.deleted.toml disabling a base catalog entry |
Grants go in allowed_operations; denied_operations subtracts from them and
wins. The retired operation names constrain and override are not accepted
and will not be reused. An operation absent from allowed_operations is denied - that's the
default-closed part.
Some shapes are deliberately not operations. Replacing a whole base catalog
entry file is rejected toward the structural shapes: "governance does not
model replacing catalog entry <entry> wholesale; use <entry>.update.toml to
update fields or <entry>.deleted.toml to disable it". Replacing a lint file
the base owns is rejected outright.
Base schema files are in that group too, and it's worth saying why. Under a
governed base, an overlay can never change what the base declared under
model/: not a catalog schema, not a list declaration, not an evaluation
context schema or its samples. There is no grant for it. The reason is that a
schema edit can widen a contract just as easily as narrow it, and no one can
tell the difference by looking at the grant. When an overlay genuinely needs a
tighter contract than its base ships, it writes a custom lint rule under
lint/: the base's schema stays the shared floor, and the overlay's lint rule
is the overlay's own, reviewable, tightening on top of it.
Scoping update and delete
Only update and delete carry a scope, through the optional
update_policy and delete_policy tables. Each takes up to four arrays:
allowed_entries/denied_entries- which entry ids the operation may touch.allowed_fields/denied_fields- which top-level fields an update may change. Field patterns ondelete_policyare a lint error; a delete has no field scope.
The items are literal ids or * globs (* matches any run of characters,
everything else is literal - acme_*, *_hero). The resolution rules:
- An allowlist restricts when present. No
allowed_entriesmeans every entry passes the allow side. An empty allowlist is a lint error - "listed nothing" reads two ways, so name targets or drop the key. - A denylist subtracts and wins absolutely. An id matching a denied pattern fails, whatever the allowlist says.
- An
updatemust pass both dimensions: the field passes the field patterns and the entry passes the entry patterns.
Field names are a fixed set, so a field pattern that matches nothing the catalog schema declares is a lint error. Entry patterns are not checked that way, because they may name entries an overlay adds later.
The ceiling: grants only narrow
Governance stacks. An overlay's own governance.toml is its grant to its
overlays, and it must fit inside the ceiling it inherited: every operation
it allows, and every policy pattern it lists, has to be something its base
granted it. A wider grant is rejected at compose time, not silently
clamped - governance grant exceeds the inherited ceiling: ... - so the
author sees it and either drops the rule or asks the base to widen.
That's the dial model made concrete: each successive overlay can keep the dial where it is or turn it further down, never back up.
Lint on the file itself
Four rules cover the contract file:
rototo/governance-parse-failed- the TOML doesn't parse.rototo/governance-shape- structural problems: an unknown kind or key, an operation name outside the four, a deadupdate_policy/delete_policyfor an operation the block doesn't allow, an empty allowlist, field scopes ondelete_policy, or a field name the catalog schema doesn't declare.rototo/governance-unknown-target- a[<kind>.<id>]block names an entity the package doesn't declare.rototo/governance-unscoped-update- a warning: anupdategrant withoutallowed_fieldssilently includes every field someone adds to the schema later. List the fields the overlay may change.
Variables: the values your app actually reads
A variable is the thing your application asks for at runtime. It has a type, a default, and an optional set of rules that override the default when some condition holds.
The simplest kind is a plain on/off flag. Here's user_is_admin.toml:
schema_version = 1
description = "Whether the user should receive admin UI affordances"
type = "bool"
[resolve]
default = false
[[resolve.rule]]
when = 'variables["admin_users"]'
value = true
Read it top to bottom and it tells a story: this is a boolean; by default it's
false; but for admin users, it's true. When your app resolves this variable,
rototo checks the rules in order, takes the value of the first rule whose
when matches, and falls back to default if none do.
The fields:
schema_version- always1.description- optional, recommended.type- required. What kind of value this is (next section).[resolve]- required. Holds an optionalmethod, thedefault, and the rules.method- optional,"rules","query", or"allocation". Absent means"rules": the first matching rule's value wins, else the default."query"swaps the rules for a catalog query; the fields for that live in Catalog queries below."allocation"swaps them for an arm assignment from a layer; see Layers below.default- required under the rules method.[[resolve.rule]]- zero or more. Each has awhencondition and avalue.
Both the default and every rule value have to match the declared type - rototo
checks that for you, so a bool variable can't accidentally default to a string.
Some old syntax is gone: a top-level schema field and a [values] section are
both rejected. Declare a type and put your literal values directly under
[resolve].
Condition variables: naming a runtime condition
That variables["admin_users"] in the rule above deserves a closer look. "Is
this a premium user?" "Is this request coming from Europe?" Conditions like
these tend to show up in more than one variable, and repeating the same
expression everywhere is how definitions drift apart.
The fix is to give the condition a name - and in rototo, a named condition is
just a bool variable. By convention we call it a condition variable: type
bool, default false, and a rule that flips it to true when the condition
holds. Here's eu_users.toml:
schema_version = 1
description = "Users whose country is in the European operating region"
type = "bool"
[resolve]
default = false
[[resolve.rule]]
when = '(context.request.country in ["DE","FR","ES","IT","NL","SE"])'
value = true
Any other variable's rule can now read that condition by name, with the
variables["<id>"] root. Conditions can lean on each other, too. This one is
true only when two other condition variables are both true:
schema_version = 1
description = "Premium users who are also in the beta rollout bucket"
type = "bool"
[resolve]
default = false
[[resolve.rule]]
when = '(variables["premium_users"]) && (variables["beta_rollout_bucket"])'
value = true
There's nothing special about a condition variable to rototo - it resolves like
any other bool, and your app can resolve it directly if it wants the yes/no
answer itself. The convention is for readers: a bool named eu_users with a
false default reads as "the condition this package uses to mean an EU user."
How variables[...] references work (lazy, memoized, cycles rejected) is
covered in the expressions reference.
(Older packages had a separate qualifiers/ folder for this. Qualifiers were
dissolved into condition variables; a qualifiers/ directory is no longer part
of the format.)
Variable types
The type field decides what shape a value can take. The built-in types are:
| Type | What it is |
|---|---|
bool | true / false |
int | a whole number |
number | a number with a fractional part |
string | text |
array | a plain array of values |
catalog=<id> | one entry from a catalog (see below) |
list=<id> | one member of a named list (see below) |
array<...> | an array of a specific item type |
The array<...> form lets you say what's in the array. The item can be a
primitive, a catalog reference, or a list - array<string>, array<int>,
array<catalog=payment_methods>, array<list=plan_tiers>. What you can't do is
nest arrays inside arrays: array<array<string>> is rejected. One level deep is
the limit.
Here's a plain array variable, payment_methods.toml:
schema_version = 1
description = "Payment methods enabled at checkout"
type = "array"
[resolve]
default = ["card", "paypal"]
[[resolve.rule]]
when = 'variables["mobile_users"]'
value = ["card", "apple_pay", "google_pay"]
Catalogs: values with a real shape
Sometimes a value isn't a single number or string - it's a structured object with several fields, and you've got a few named versions of it. A checkout page layout, say: each variant has a heading, a subheading, an image, some body copy. That's what a catalog is for. It's a set of named entries, all sharing one schema.
A catalog comes in two parts, and they live on the two sides of the
model/data split. First, the schema, at model/catalogs/<id>.schema.json - an
ordinary JSON Schema describing what every entry must look like. Here's
checkout_redesign.schema.json:
{
"description": "Checkout page content and layout entries",
"type": "object",
"required": ["variant", "heading", "subheading", "image_url", "content"],
"properties": {
"variant": { "type": "string" },
"heading": { "type": "string" },
"subheading": { "type": "string" },
"image_url": { "type": "string" },
"content": { "type": "string" }
},
"additionalProperties": false
}
Second, the entries, each a TOML file under data/catalogs/<id>/. The
path under the catalog's directory is the entry's id, so entries can
namespace with subdirectories: promo/summer.toml is the entry
promo/summer. One rule keeps that unambiguous: no catalog id may be a
path prefix of another (rototo/catalog-id-overlap), so a file always has
exactly one owner. Here's data/catalogs/checkout_redesign/control.toml:
variant = "control"
heading = "Complete your purchase"
subheading = "You're almost done"
image_url = "/images/checkout/control.png"
content = "Secure checkout in seconds."
rototo converts that TOML to JSON and checks it against the schema, so an entry that's missing a field or has a typo gets caught at lint time, not in production.
To use a catalog, give a variable the type catalog=<id> and let its values be
entry ids:
schema_version = 1
description = "Checkout page content and layout variant"
type = "catalog=checkout_redesign"
[resolve]
default = "control"
[[resolve.rule]]
when = 'variables["premium_users"]'
value = "premium"
The variable resolves to an entry id like "control", and rototo hands your app
the full structured entry behind it.
Catalog queries: picking entries from data
Sometimes which entry applies is a data question, not a rule question. "Every
enabled payment method." "The pricing plan whose tier matches the account."
Writing one when/value rule per entry just duplicates what the entries
already say, and it goes stale the moment someone adds an entry. Setting
method = "query" turns the resolve block into a small pipeline over one
catalog's entries instead:
schema_version = 1
type = "array<catalog=llm_parameters>"
[resolve]
method = "query"
from = "llm_parameters"
filter = "entry.enabled == true"
The query keys sit flat on [resolve]:
from- required, a string. The id of the catalog to read entries from. It must be the same catalog named in the variable'stype, and it must exist (rototo/variable-unknown-catalogif it doesn't).filter- optional, a boolean expression run once per entry.entryis the entry under consideration, andcontext,variables[...], andenv.noware available exactly as in a rule'swhen. Entries where it comes out true stay; with nofilter, every entry stays.sort- optional, an expression evaluated once per entry that produces that entry's sort key. The keys have to be mutually comparable - all numbers or all strings. Mixed kinds are a resolution error.order- optional,"asc"(the default) or"desc". Requiressort.limit- optional, a positive integer. After sorting, keeps at most that many entries.default- optional. The usual resolve default, used when the query matches nothing.
What the query produces depends on the variable's type:
type = "array<catalog=<id>>"- the value is every matching entry, aftersortandlimit. No matches means thedefaultif you declared one, otherwise an empty array.type = "catalog=<id>"- the value is one entry. With asort, the top entry wins. Without one, thefilterhas to match exactly one entry; matching several is a resolution error (add asortor narrow the filter). No matches means thedefaultif you declared one, otherwise an error.
Methods don't mix. method = "query" must not declare
[[resolve.rule]] tables, and the query keys are rejected under the rules
method. Lint enforces all of this shape as rototo/variable-query-shape.
Layers: shared assignment for rollouts and experiments
Rolling a change out to 20% of users, or A/B testing two versions of the checkout copy, needs a deterministic answer to "which variant does this user get?" - the same answer on every request, and the same answer for every variable the experiment drives. If the layout, the copy, and the CTA each hashed the user independently, one user could see the new layout with the old copy. That's why the assignment is a shared, named thing in the package - an allocation inside a layer - and not a per-variable setting.
The mental model: a layer hashes each unit (say, context.user.id) to a
stable position on a line of buckets, and that position never moves. An
allocation claims a set of those buckets and divides them among arms.
Allocations in one layer claim disjoint buckets, so a unit sits in at most one
of them. Different layers are independent lines - it's safe for the same user
to be in an experiment in two different layers, because each layer's
allocations drive different variables. A rollout and an experiment are the
same shape used differently: a rollout is one arm growing its bucket range, an
experiment is two or more arms splitting one.
The layer file
Each layer is one TOML file under layers/, and as usual the file stem is the
layer's id (snake_case). Here's layers/checkout.toml from examples/basic:
schema_version = 1
description = "Checkout page experiments, diverted by user id"
unit = "context.user.id"
buckets = 1000
[[allocation]]
id = "cta_copy_test"
status = "running"
eligibility = '!variables["enterprise_accounts"]'
[[allocation.arm]]
name = "control"
buckets = "0-499"
[[allocation.arm]]
name = "benefit_led"
buckets = "500-999"
The layer-level fields:
schema_version- always1(rototo/layer-schema-versionif it isn't).description- optional, recommended.unit- required. A CEL expression overcontextthat produces the value to hash - usually a stable id like the user id. It readscontextonly: novariables, noentry.buckets- required. A positive integer, the length of the line. Buckets are numbered0tobuckets - 1.
Then each [[allocation]]:
id- required. Unique across all layers, not just this one, because a variable names its allocation without a layer qualifier.status- optional, one of"draft","running", or"concluded". Defaults to"running". Only a running allocation assigns arms; while an allocation is draft or concluded, every unit resolves to the variable's default. That makes concluding an experiment a package edit, not a scramble.eligibility- optional. A boolean expression deciding who is enrolled at all. It can readcontextandvariables[...]- so a condition variable likeenterprise_accountscan keep a whole class of accounts out of an experiment - but notentry.
And each [[allocation.arm]]:
name- required, snake_case, unique within the allocation.buckets- required. An inclusive range string like"0-499", or a single bucket like"7".
Arms across all allocations in a layer must claim disjoint buckets -
rototo/layer-bucket-overlap if two claims collide. Buckets nobody claims are
fine; a unit landing there is simply in no allocation. A file that doesn't
parse is rototo/layer-parse-failed, and anything else structurally off
(a missing unit, a zero buckets, a malformed range, a duplicate arm name)
is rototo/layer-shape.
The variable side: method = "allocation"
A variable joins an allocation by declaring the third resolve method. Here's
variables/checkout_cta_copy.toml:
schema_version = 1
description = "Call-to-action copy on the checkout button"
type = "string"
[resolve]
method = "allocation"
allocation = "cta_copy_test"
default = "Place order"
[[resolve.assign]]
arm = "control"
value = "Place order"
[[resolve.assign]]
arm = "benefit_led"
value = "Place order, arrives in 2 days"
allocation- required, the id of exactly one allocation. Since allocation ids are globally unique, this also pins the variable to exactly one layer. An id no layer declares isrototo/variable-unknown-allocation.default- required. Any unit that doesn't get an arm - ineligible, in an unclaimed bucket, or in a draft or concluded allocation - resolves to it.[[resolve.assign]]- exactly one per arm of the allocation. Each has thearmname and thevaluethat arm assigns. A missing arm, a stray arm the allocation doesn't declare, or a duplicate isrototo/variable-allocation-shape.
Assign values are type-checked against the variable's declared type, exactly
like rule values. And as with queries, methods don't mix: method = "allocation" must not declare [[resolve.rule]] tables or query keys.
At resolve time, rototo evaluates the layer's unit expression against the
caller's context, hashes the result (FNV-1a, salted with the layer id, so it's
deterministic and stable across rototo releases), and lands on a bucket. If the
allocation is running and the unit passes eligibility, the arm claiming that
bucket assigns its value. The resolution trace records the layer, the
allocation, enrollment, the bucket, and the arm, and rototo resolve prints
the assignment in the pathway:
allocation checkout/cta_copy_test -> bucket 967 -> arm benefit_led
One boundary worth stating: rototo's job here is assignment - deterministic, reproducible, recorded in the trace. Exposure logging ("unit U saw arm A") belongs to the consuming app or SDK, and shipping the winning arm is a package edit, not runtime state.
Lists: closed sets of scalar values
A lot of configuration values aren't free-form. A plan tier is one of free,
team, or business - never anything else. If someone types "buisness" in a
rule value, you want that package to be unreleasable, not quietly shipping a
tier no code path handles.
You could declare the field as a plain string and hope review catches the
typo. Or you could build a catalog - but a catalog is for structured objects
with several fields, and it's heavy machinery for "one of these five strings."
A list is the lightweight middle: it names a closed set of scalar values,
and lint checks every use against that set.
A list is one file, lists/<id>.toml, holding both the contract and the
values, the way a variable holds its type and its resolution:
schema_version = 1
description = "Account plan tiers"
type = "string"
members = ["free", "team", "business"]
The type is one of string, int, number, or bool. The member set
has to be non-empty, free of duplicates, and every member has to match the
declared type.
To use a list, give a variable the type list=<id>:
schema_version = 1
description = "The plan tier this account resolves to"
type = "list=plan_tiers"
[resolve]
default = "free"
[[resolve.rule]]
when = 'context.account.paid == true'
value = "team"
Now every default and every rule value is checked against the member set. A
typo'd "buisness" fails lint, and so does a list=<id> type that names a
list the package doesn't declare. Lists also work inside schemas, through
x-rototo-ref - that's next.
Schema references: x-rototo-ref
Catalog entries and context facts sometimes need to point at things the package
already defines. A page entry names its hero banner; a notification policy
names its email template. If that's just a plain string field, a renamed or
deleted target breaks silently. The x-rototo-ref annotation makes the link
explicit, so lint can verify it and resolution can follow it.
You put it on a field inside a JSON Schema, with a kind-prefixed target. The target says what the field's values must be.
"x-rototo-ref": "catalog=<id>" pins a string field to the entry ids of
another catalog:
{
"type": "object",
"required": ["hero", "title"],
"properties": {
"hero": { "type": "string", "x-rototo-ref": "catalog=hero_banner" },
"title": { "type": "string" }
}
}
An entry that sets hero = "home" now has to point at a real entry in the
hero_banner catalog, and lint fails if it doesn't
(rototo/catalog-entry-unknown-reference). A value can also reach inside
the target with "<entry>#<json-pointer>", like "home#/cta", to name one
field.
What the reference does at resolve time is deliberately split. A query's
filter and sort see the hydrated view: entry.hero is the full
hero_banner entry (or the pointed-at field), so predicates can select on
referenced data. The value your app receives stays raw: hero is the
string "home", exactly as authored, and an app that wants the banner
follows the reference explicitly through the SDK's lookup surface. That
keeps resolved value shapes independent of how much of the reference graph
any one app needs.
The target can be an array, ["catalog=email_template", "catalog=sms_template"],
when a field may point into any of several catalogs. Lint then checks the value
against all of them and flags an entry id that exists in more than one - an
ambiguous reference is an error, not a guess.
"x-rototo-ref": true is the object form, for when the value names the
catalog. The field is an object with catalog and entry keys, plus an
optional pointer:
{
"type": "object",
"required": ["catalog", "entry"],
"properties": {
"catalog": { "type": "string" },
"entry": { "type": "string" },
"pointer": { "type": "string", "format": "json-pointer" }
},
"x-rototo-ref": true
}
"x-rototo-ref": "list=<id>" pins a field to a list's member set:
{
"type": "object",
"required": ["tier"],
"properties": {
"tier": { "type": "string", "x-rototo-ref": "list=plan_tiers" }
}
}
List targets don't hydrate - the member already is the value - but they do get checked: catalog entry values and evaluation-context sample values both have to be members of the list.
Where each target is allowed: catalog schemas can use catalog targets, list
targets, and the object form. Evaluation-context schemas accept only list
targets - context facts are caller data, so pointing them at catalog entries
doesn't mean anything, but pinning a fact like account.tier to a closed set
does.
Evaluation contexts: the facts your app passes in
When your app asks rototo to resolve a variable, it passes in a bundle of facts about the current request - who the user is, what country they're in, what's in their cart. That bundle is the evaluation context, and an evaluation-context schema pins down its shape so the package and the app can't quietly disagree about it.
The schema lives at model/context/<id>.schema.json - again, plain JSON
Schema. (The concept keeps its full name, evaluation context; only the path is
short.) Here's a trimmed request.schema.json:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "Evaluation context contract for request runtime inputs.",
"type": "object",
"additionalProperties": false,
"properties": {
"user": {
"type": "object",
"properties": { "id": { "type": ["string", "integer"] }, "tier": { "type": "string" } }
},
"account": {
"type": "object",
"properties": { "plan": { "type": "string" }, "seats": { "type": "integer" } }
},
"request": {
"type": "object",
"properties": { "country": { "type": "string" } }
}
}
}
This is what lets lint catch drift: if a rule reads context.user.tier but
your schema never mentions it, that's a problem you want to hear about before a
release, not during one.
Alongside the schema you can keep sample contexts, in
model/context/<id>-samples/. Each is a JSON file - the path under the
samples directory is the
sample's id - that has to validate against the schema, including any
x-rototo-ref list pins the schema declares. Here's
premium_enterprise.json:
{
"user": { "id": "user-123", "tier": "premium", "role": "admin" },
"account": { "plan": "enterprise", "seats": 250 },
"cart": { "total_usd": 300 },
"device": { "platform": "web" },
"request": { "country": "DE" }
}
Samples earn their keep three ways: they're realistic inputs you can feed to
rototo resolve while testing, they give lint real data to check rule coverage
against, and they make handy examples in docs and reviews.
Custom lint: your own rules, in Lua
rototo's built-in lint checks the structural stuff - that
files parse, references resolve, values match their types. But some rules are
specific to your domain and rototo can't guess them. "Users on the standard
tier must never get more than five projects." "A checkout heading can't be
empty." Those go in lint/, written in Lua.
A lint file defines a register function, and inside it you register one or more
rules. Here's checkout_redesign.lua:
function register(lint)
lint:rule({
id = "consumer-experience/checkout-heading-required",
title = "Checkout heading is missing",
help = "Set heading to visible checkout copy.",
target = "catalog=checkout_redesign:entry=",
handler = "check_heading",
})
end
function check_heading(package, entry)
if is_checkout_value(entry.value) and entry.value.heading == "" then
return {{ message = "checkout value " .. entry.key .. " must include heading", path = "/value/heading" }}
end
return {}
end
A rule registration has five parts:
id- the rule's identity, writtenauthority/rule-id. The authority is yours to name (here,consumer-experience) - anything exceptrototo, which is reserved for built-in rules.title- a short summary of what went wrong.help- how to fix it.target- what the rule looks at (here, the entries of a catalog).handler- the name of the function rototo calls for each target.
There's also an optional severity ("error" or "warning", default
"error"), and target itself defaults to package=, the whole package.
The handler returns an array of problems. Each problem just needs a message;
returning an empty list {} means "all good." A problem can also carry a
path (a pointer into the target's value, used to anchor the diagnostic to
the exact line) and a field. The diagnostics
reference covers how these show up next to the built-in ones.
Target addresses
target is an address from rototo's addressing grammar, not a file path:
<class>=<id>, with : between containment steps. An address that names a
group runs the handler once per member; a concrete one pins a single target:
package=- the whole package, once. This is the default whentargetis omitted.variable=,variable=<id>- every variable, or one. Namespaced ids work as written:variable=payments/max_tokens.variable=<prefix>/- a namespace subtree: every variable underpayments/, one run each.catalog=,catalog=<id>- every catalog, or one.catalog=<id>:entry=,catalog=<id>:entry=<key>- a catalog's entries. This is the workhorse: one handler, run once per entry.evaluation-context=,evaluation-context=<id>- context schemas.evaluation-context=<id>:sample=,evaluation-context=<id>:sample=<key>- a context's samples.
Rules are not separately addressable anymore; target the variable and read
its resolve data in the handler. The old /variables/... path spellings
are rejected with a hint that shows the new form.
What the handler receives
The handler's first argument is always the whole package: root,
manifest, and maps of variables, catalogs, and evaluation_contexts by
id, so a rule about one entry can still cross-check anything else. The second
argument is the target instance, and its shape follows the address. Every
shape carries a kind field naming itself:
- a catalog entry is
{ kind, catalog, key, path, value }-valueis the entry's data, which is what most rules inspect; - a variable is
{ kind, id, path, description, declaration, values, resolve, toml }; - a catalog is
{ kind, id, path, json, entries }-jsonis the schema; - an evaluation context is
{ kind, id, path, json, samples }; - a sample is
{ kind, evaluation_context, key, path, value }; - the package target (
package=) gets{ kind, root, manifest, extends }- everything else is already on the first argument.
How it all gets distributed
When you're ready to ship a package to production, rototo package bundles this
whole folder into a single .tar.gz file. That archive is deterministic:
the same package always produces the exact same bytes - entries are sorted,
timestamps and permissions are fixed, ownership is zeroed, compression is
pinned. Because the contents fully determine the bytes, the archive is named by
its own SHA-256 digest, like sha256:0f4c...b91.tar.gz.
That determinism is what makes a release trustworthy: the same commit always gives the same digest, so a digest is a precise, reproducible name for "exactly this config." How you serve and load that archive is the package sources story.
Either way it is written, the output is the projection: extends parents
merged in, update and deleted markers consumed, the extends key stripped from
the manifest, and the review-time lint/ directory left out (packaging already
required the package to be lint-clean, so the artifact carries only what the
runtime reads). If you want to look at that projection instead of shipping it,
rototo package --unpacked <dir> writes the same tree as a plain directory.
That's the fastest way to answer "what does this overlay actually compose to?"
without untarring anything.