rototo 0.1.0-alpha.8

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
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
# 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:

```text
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](./expressions.md), 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:

```toml
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](./package-sources.md):

```toml
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](./package-sources.md#the-rules-for-sources-in-extends). (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](#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:

```toml
[[trace]]
when = 'env.resolving.variable == "checkout_redesign" && context.user.id == "user-123"'
```

The `when` is an [expression](./expressions.md) - same language as everywhere
else. We cover what tracing is for in [Using Rototo](./adoption.md); 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](#governance-governancetoml) 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](#governance-governancetoml), 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.toml` updates the base variable. The marker may
  carry only `[resolve]` and `description` - 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>.toml` is 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 with
  `variables/<id>.update.toml` instead 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.toml` removes 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
  contains `deleted = true` and an optional `reason = "..."`.
- `data/catalogs/<catalog>/<entry>.update.toml` updates 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:

```toml
# 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`:

```toml
[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:

```toml
[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 on `delete_policy` are 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_entries` means 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 `update` must 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 dead `update_policy`/`delete_policy` for
  an operation the block doesn't allow, an empty allowlist, field scopes on
  `delete_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: an `update` grant without
  `allowed_fields` silently 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`:

```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` - always `1`.
- `description` - optional, recommended.
- `type` - required. What kind of value this is (next section).
- `[resolve]` - required. Holds an optional `method`, the `default`, 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]#catalog-queries-picking-entries-from-data below.
  `"allocation"` swaps them for an arm assignment from a layer; see
  [Layers]#layers-shared-assignment-for-rollouts-and-experiments below.
- `default` - required under the rules method.
- `[[resolve.rule]]` - zero or more. Each has a `when` condition and a `value`.

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`:

```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:

```toml
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](./expressions.md).

(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`:

```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`:

```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`:

```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:

```toml
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:

```toml
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's `type`, and it must exist
  (`rototo/variable-unknown-catalog` if it doesn't).
- `filter` - optional, a boolean [expression]./expressions.md run once per
  entry. `entry` is the entry under consideration, and `context`,
  `variables[...]`, and `env.now` are available exactly as in a rule's `when`.
  Entries where it comes out true stay; with no `filter`, 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"`. Requires `sort`.
- `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, after
  `sort` and `limit`. No matches means the `default` if you declared one,
  otherwise an empty array.
- `type = "catalog=<id>"` - the value is one entry. With a `sort`, the top
  entry wins. Without one, the `filter` has to match exactly one entry;
  matching several is a resolution error (add a `sort` or narrow the filter).
  No matches means the `default` if 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`:

```toml
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` - always `1` (`rototo/layer-schema-version` if it isn't).
- `description` - optional, recommended.
- `unit` - required. A CEL [expression]./expressions.md over `context` that
  produces the value to hash - usually a stable id like the user id. It reads
  `context` only: no `variables`, no `entry`.
- `buckets` - required. A positive integer, the length of the line. Buckets
  are numbered `0` to `buckets - 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 read `context` and `variables[...]` - so a condition variable
  like `enterprise_accounts` can keep a whole class of accounts out of an
  experiment - but not `entry`.

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`:

```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 is `rototo/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 the
  `arm` name and the `value` that arm assigns. A missing arm, a stray arm the
  allocation doesn't declare, or a duplicate is `rototo/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:

```text
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:

```toml
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>`:

```toml
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:

```json
{
  "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`:

```json
{
  "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:

```json
{
  "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`:

```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`:

```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](./diagnostics.md) 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`:

```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, written `authority/rule-id`. The authority is
  yours to name (here, `consumer-experience`) - anything except `rototo`, 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](./diagnostics.md) 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 when `target`
  is omitted.
- `variable=`, `variable=<id>` - every variable, or one. Namespaced ids
  work as written: `variable=payments/max_tokens`.
- `variable=<prefix>/` - a namespace subtree: every variable under
  `payments/`, 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 }` - `value` is
  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 }` - `json` is 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](./package-sources.md) 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.