rototo 0.1.0-alpha.3

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
# Production Workflow

The Adopt pages before this one define how I would run rototo in production:
model the runtime decision, integrate through the SDK, test the app-workspace
contract, and treat workspace changes as releases.

Here is that approach as one concrete path. We continue the `account-config`
workspace and `account-app` from getting started, then add the pieces I would
want before trusting it in a service: a named condition, a context contract, a
hosted workspace source, workspace policy lint, merge gates, app contract
tests, and runtime observability.

The core split does not change. The application is still deployed with a
workspace source URI. The app still supplies runtime facts. The workspace still
owns the policy for selecting the value.

## Add The Runtime Condition

The first production gap is that account limits should vary by account facts.
Premium accounts should receive a larger `max-active-projects` value, but that
condition should not be hidden in app code.

Create `account-config/qualifiers/premium-account.toml`:

```toml
schema_version = 1
description = "Requests from premium accounts"

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

Update `account-config/variables/max-active-projects.toml`:

```toml
schema_version = 1

description = "Maximum active projects for an account"
type = "int"

[values]
standard = 3
premium = 25

[resolve]
default = "standard"

[[resolve.rule]]
qualifier = "premium-account"
value = "premium"
```

The qualifier gives the condition a name. The variable rule can now say
`premium-account -> premium`, and the app does not need to know how premium is
defined.

## Add The Context Contract

The workspace now depends on `account.plan`. That path is part of the contract
between the app and workspace, so it should be validated.

Generate a context schema skeleton:

```sh
rototo init account-config --context
```

For this workspace, that writes `account-config/schemas/context.schema.json`
with the context path used by the qualifier:

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

Review this file. The generator gives you a starting point; it does not know
which fields should be required, which values are allowed, or which app boundary
owns them.

Run lint:

```sh
rototo lint account-config
```

Then resolve both paths with the same facts the app will send:

```sh
rototo resolve account-config \
  --variable max-active-projects \
  --context account.plan=standard

rototo resolve account-config \
  --variable max-active-projects \
  --context account.plan=premium
```

The app should now build context from account facts instead of sending an empty
object:

```rust
let account_plan =
    std::env::var("ACCOUNT_PLAN").unwrap_or_else(|_| "standard".to_owned());
let context = ResolveContext::from_json(serde_json::json!({
    "account": {
        "plan": account_plan
    }
}))?;
```

Run the app as a premium account:

```sh
ACCOUNT_PLAN=premium cargo run -- ../account-config
```

At this point the split is visible: the app supplies facts, and the workspace
decides which configured value those facts select.

## Publish The Workspace Source

A production service should load a source it can fetch from its runtime
environment. Since git is the source of truth, publish `account-config` as a
private repository and pass the app a git workspace URI.

The following commands use the GitHub CLI and SSH. The runtime environment needs
an SSH key or deploy key that can read the repository.

```sh
cd /path/to/account-config

git init .
git add .
git commit -m "Initialize account config workspace"
git branch -M main

GITHUB_OWNER="$(gh api user --jq .login)"
gh repo create "$GITHUB_OWNER/account-config" \
  --private \
  --source . \
  --remote origin \
  --push

export WORKSPACE_URI="git+ssh://git@github.com/${GITHUB_OWNER}/account-config.git#main"
```

Run the app with the hosted source:

```sh
cd /path/to/account-app
ACCOUNT_PLAN=premium cargo run -- "$WORKSPACE_URI"
```

The `#main` ref means refreshes can discover later reviewed commits on `main`.
Use a full commit SHA when a job or deployment needs exact reproducibility; that
source will not discover newer commits through refresh.

## Add Workspace Policy Lint

Built-in lint protects rototo's structural contracts. The workspace also needs
local policy: account project limits should be positive, stay under an
operational ceiling, and keep the standard plan from accidentally exceeding the
premium plan.

That policy belongs with the workspace because reviewers need to see the values
and the guardrail together.

Create `account-config/lint/max-active-projects.lua`:

```lua
function register(lint)
  lint:on({
    stage = "policy",
    entity = "variable",
    rule = {
      id = "operations/max-active-projects-policy",
      title = "Account project limit violates operations policy",
      help = "Keep max-active-projects values between 1 and 100 and keep standard <= premium.",
    },
    handler = "check_max_active_projects",
  })
end

function check_max_active_projects(ctx)
  if ctx.target.id ~= "max-active-projects" then
    return {}
  end

  local values = ctx.target.toml.values or {}
  local diagnostics = {}

  for name, value in pairs(values) do
    if type(value) ~= "number" or value < 1 or value > 100 then
      table.insert(diagnostics, {
        message = "max-active-projects." .. name .. " must be between 1 and 100"
      })
    end
  end

  if type(values.standard) == "number"
      and type(values.premium) == "number"
      and values.standard > values.premium then
    table.insert(diagnostics, {
      message = "max-active-projects.standard must not exceed max-active-projects.premium"
    })
  end

  return diagnostics
end
```

Run lint again:

```sh
rototo lint account-config
```

The custom rule uses the `operations/` authority. Built-in rototo rules stay
under the `rototo/` authority, which keeps diagnostic ownership clear.

## Put Gates Before Merge

The workspace repository should reject bad edits before they reach the branch
that services refresh from. Use a local hook for fast feedback and CI for the
shared gate.

Add `.pre-commit-config.yaml` to `account-config`:

```yaml
repos:
  - repo: local
    hooks:
      - id: rototo-lint
        name: rototo lint
        entry: rototo lint .
        language: system
        pass_filenames: false
```

Install the hook:

```sh
pre-commit install
```

Add `.github/workflows/rototo.yml`:

```yaml
name: Rototo

on:
  pull_request:
  push:
    branches:
      - main

permissions:
  contents: read

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: dtolnay/rust-toolchain@stable
      - run: cargo install rototo --locked
      - run: rototo lint .
```

Now the workspace has the same release discipline as code: edit, lint locally,
open a PR, run CI, review the runtime behavior delta, and merge.

## Test The App Contract

Workspace lint proves the workspace is valid. App tests prove the application
can still consume the selected values and apply the policy.

Generate readable behavior fixtures:

```sh
rototo fixtures account-config \
  --variable max-active-projects \
  --qualifier premium-account \
  --out account-app/tests/rototo-fixtures
```

Commit the generated `tests/rototo-fixtures` directory with the app tests. The
fixture diff should be part of review when runtime behavior intentionally
changes.

Add `account-app/tests/rototo_contract.rs`:

```rust
use std::error::Error;

use rototo::{ResolveContext, Workspace};

#[tokio::test]
async fn rototo_workspace_fixtures_still_hold() -> Result<(), Box<dyn Error>> {
    let source = std::env::var("ROTOTO_WORKSPACE_SOURCE")
        .unwrap_or_else(|_| "../account-config".to_owned());
    let workspace = Workspace::load(source).await?;

    let report =
        rototo::testing::assert_fixtures(&workspace, "tests/rototo-fixtures").await?;
    assert!(report.cases > 0);

    Ok(())
}

#[tokio::test]
async fn max_active_projects_deserializes_for_app_contexts() -> Result<(), Box<dyn Error>> {
    let source = std::env::var("ROTOTO_WORKSPACE_SOURCE")
        .unwrap_or_else(|_| "../account-config".to_owned());
    let workspace = Workspace::load(source).await?;

    let standard = ResolveContext::from_json(serde_json::json!({
        "account": { "plan": "standard" }
    }))?;
    let premium = ResolveContext::from_json(serde_json::json!({
        "account": { "plan": "premium" }
    }))?;

    let standard = workspace
        .resolve_variable("max-active-projects", &standard)
        .await?;
    let premium = workspace
        .resolve_variable("max-active-projects", &premium)
        .await?;

    assert_eq!(standard.value_key, "standard");
    assert_eq!(premium.value_key, "premium");

    let standard: i64 = serde_json::from_value(standard.value)?;
    let premium: i64 = serde_json::from_value(premium.value)?;

    assert_eq!(standard, 3);
    assert_eq!(premium, 25);

    Ok(())
}
```

Run the app tests against the local workspace:

```sh
cd /path/to/account-app
cargo test
```

In CI, set `ROTOTO_WORKSPACE_SOURCE` to the same git source URI the service
uses when the app repository should test against the hosted workspace.

## Release And Observe

Before merging a workspace change, the pull request should say what behavior
can change and how to recover:

```text
Change max-active-projects:
- add premium-account rule
- standard accounts keep value key standard
- premium accounts select value key premium
- rototo lint and account-app contract tests passed
- rollback: revert this workspace commit
```

After merge, services following the branch source can refresh to the new
workspace. The application binary does not redeploy, but future resolutions can
change.

The service should log the selected value key and workspace fingerprint near the
behavior boundary:

```rust
let resolution = workspace
    .resolve_variable("max-active-projects", &context)
    .await?;

tracing::info!(
    variable = "max-active-projects",
    value_key = %resolution.value_key,
    workspace_fingerprint = ?workspace.current().await.source_fingerprint(),
    account_plan = %account_plan,
    "resolved runtime configuration"
);
```

It should also expose refresh status:

```rust
let status = workspace.status().await;
if status.consecutive_failures > 0 {
    tracing::warn!(
        consecutive_failures = status.consecutive_failures,
        last_error = ?status.last_error,
        "workspace refresh is failing; serving last-known-good configuration"
    );
}
```

If the policy is wrong, revert the workspace commit. If the app sent the wrong
context or cannot consume the selected value, fix the app-workspace contract and
redeploy the app.

## What This Workflow Gives You

The final system has one clear path:

1. The app is deployed with a workspace source URI.
2. The SDK loads and lints that source at startup.
3. The app supplies runtime facts as context.
4. The workspace evaluates named conditions and variables.
5. Tests prove the workspace and app still agree.
6. Refresh lets reviewed workspace commits affect future resolutions.
7. Last-known-good state protects running services from failed refreshes.
8. Logs and refresh status explain what value was selected and from which
   workspace version.

That is the production goal: runtime configuration can move independently from
the application binary, while still going through review, validation, tests,
observability, and git-backed recovery.