rototo 0.1.0-alpha.4

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
# Notification Delivery Policy

Some configuration is policy for another runtime system. Notification delivery
is a good example. The notification service needs to know whether to deliver a
message immediately or in a digest, which channels to try, whether quiet hours
matter, and what fallback channel is allowed.

I do not want those rules scattered across notification code. I also do not
want rototo to become the notification system. The boundary I want is smaller:
rototo selects a reviewed delivery policy from runtime facts, and the
notification service enforces that policy while it owns recipients,
subscriptions, consent, retries, and delivery logs.

We will model that as `notification-config`, with one variable named
`notification-delivery-policy`.

## Start With The Boundary

The runtime question is not "is notification delivery enabled?" It is:

```text
Given this notification, account, and operating state, which reviewed delivery
policy should the notification service use?
```

The app will supply context like this:

```json
{
  "account": {
    "plan": "enterprise"
  },
  "notification": {
    "kind": "incident_update"
  },
  "incident": {
    "active": true
  }
}
```

Rototo will return a policy object. The notification service still decides
which recipients are eligible, whether a user has opted out, whether an email
address is verified, how quiet hours map to the recipient's timezone, and how
provider retries are handled.

That is why this example uses a resource-backed variable. The variable owns
resolution. The resource owns the validated policy objects the app can consume.

## Create The Workspace

Create the workspace with a variable and a resource template:

```sh
rototo init notification-config --variable notification-delivery-policy
rototo init notification-config --resource notification-delivery-policy
```

Replace `notification-config/variables/notification-delivery-policy.toml`:

```toml
schema_version = 1

description = "Delivery policy selected for outbound notifications"
type = "resource:notification-delivery-policy"

[resolve]
default = "product_digest"
```

Replace `notification-config/resources/notification-delivery-policy.toml`:

```toml
schema_version = 1

description = "Notification delivery policy objects"
schema = "../schemas/notification-delivery-policy.schema.json"
```

The default is a digest policy. The notification service gets a valid answer
before any special runtime conditions are introduced.

## Define The Policy Shape

Before adding policy objects, define what the notification service is willing
to consume. Replace
`notification-config/schemas/notification-delivery-policy.schema.json`:

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["delivery", "channels", "respect_quiet_hours", "fallback_channel"],
  "properties": {
    "delivery": { "type": "string", "enum": ["immediate", "digest"] },
    "channels": {
      "type": "array",
      "items": { "type": "string", "enum": ["email", "in_app", "slack"] },
      "minItems": 1,
      "uniqueItems": true
    },
    "digest_interval_hours": { "type": "integer", "minimum": 1, "maximum": 168 },
    "respect_quiet_hours": { "type": "boolean" },
    "fallback_channel": { "type": "string", "enum": ["email", "in_app", "slack"] }
  },
  "additionalProperties": false,
  "allOf": [
    {
      "if": {
        "properties": { "delivery": { "const": "digest" } },
        "required": ["delivery"]
      },
      "then": {
        "required": ["digest_interval_hours"]
      }
    }
  ]
}
```

The schema is doing production work. A digest policy must say how often the
digest runs. Every policy must declare channels, quiet-hours behavior, and a
fallback channel. Rototo validates those objects during lint, before the app
loads them.

## Add The Policy Objects

Rename the generated object file from
`notification-config/resources/notification-delivery-policy-objects/default.toml`
to
`notification-config/resources/notification-delivery-policy-objects/product_digest.toml`,
then replace its contents:

```toml
delivery = "digest"
channels = ["email", "in_app"]
digest_interval_hours = 24
respect_quiet_hours = true
fallback_channel = "in_app"
```

Create
`notification-config/resources/notification-delivery-policy-objects/security_alert.toml`:

```toml
delivery = "immediate"
channels = ["email", "in_app"]
respect_quiet_hours = false
fallback_channel = "email"
```

Create
`notification-config/resources/notification-delivery-policy-objects/enterprise_incident.toml`:

```toml
delivery = "immediate"
channels = ["email", "slack", "in_app"]
respect_quiet_hours = false
fallback_channel = "email"
```

These files are not notification messages. They are delivery policies. Product
updates can wait for a digest. Security alerts should go immediately. Active
incident updates for enterprise accounts can use Slack as one of the delivery
channels.

## Name The Runtime Conditions

Now add the conditions that select those policies.

Create `notification-config/qualifiers/security-alerts.toml`:

```toml
schema_version = 1
description = "Security notifications that should be delivered immediately"

[[predicate]]
attribute = "notification.kind"
op = "eq"
value = "security_alert"
```

Create `notification-config/qualifiers/enterprise-accounts.toml`:

```toml
schema_version = 1
description = "Enterprise plan accounts"

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

Create `notification-config/qualifiers/active-incidents.toml`:

```toml
schema_version = 1
description = "Requests made while an operational incident is active"

[[predicate]]
attribute = "incident.active"
op = "eq"
value = true
```

Create `notification-config/qualifiers/incident-updates.toml`:

```toml
schema_version = 1
description = "Notifications about an operational incident"

[[predicate]]
attribute = "notification.kind"
op = "eq"
value = "incident_update"
```

Those qualifiers name the raw facts. The delivery policy cares about a composed
condition: enterprise accounts receiving incident updates while an incident is
active.

Create `notification-config/qualifiers/enterprise-incident-updates.toml`:

```toml
schema_version = 1
description = "Enterprise accounts receiving active incident updates"

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

[[predicate]]
attribute = "qualifier.active-incidents"
op = "eq"
value = true

[[predicate]]
attribute = "qualifier.incident-updates"
op = "eq"
value = true
```

Composition keeps the variable readable. The variable can select
`enterprise_incident` without repeating the raw `account.*`, `incident.*`, and
`notification.*` predicates.

## Select The Policies

Update `notification-config/variables/notification-delivery-policy.toml`:

```toml
schema_version = 1

description = "Delivery policy selected for outbound notifications"
type = "resource:notification-delivery-policy"

[resolve]
default = "product_digest"

[[resolve.rule]]
qualifier = "security-alerts"
value = "security_alert"

[[resolve.rule]]
qualifier = "enterprise-incident-updates"
value = "enterprise_incident"
```

Rule order is part of the policy. Security alerts stay first because they are
the most direct immediate-delivery path. Enterprise incident updates come next.
Everything else gets the default digest policy.

## Generate The Context Contract

The qualifiers introduced three runtime facts: `account.plan`,
`incident.active`, and `notification.kind`. Generate the context schema after
those paths exist:

```sh
rototo init notification-config --context
```

On this workspace, rototo writes
`notification-config/schemas/context.schema.json`:

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

Now lint the workspace:

```sh
rototo lint notification-config
```

Lint checks both contracts: the context facts the app must send, and the
delivery policy objects the app may receive.

## Resolve The Policy Paths

A regular product update gets the digest policy:

```sh
rototo resolve notification-config \
  --variable notification-delivery-policy \
  --context notification.kind=product_update \
  --context account.plan=standard \
  --context incident.active=false
```

```text
value key: product_digest
value: {"channels":["email","in_app"],"delivery":"digest","digest_interval_hours":24,"fallback_channel":"in_app","respect_quiet_hours":true}
```

A security alert gets immediate delivery:

```sh
rototo resolve notification-config \
  --variable notification-delivery-policy \
  --context notification.kind=security_alert \
  --context account.plan=standard \
  --context incident.active=false
```

```text
value key: security_alert
value: {"channels":["email","in_app"],"delivery":"immediate","fallback_channel":"email","respect_quiet_hours":false}
```

An enterprise account receiving an active incident update gets the incident
policy:

```sh
rototo resolve notification-config \
  --variable notification-delivery-policy \
  --context notification.kind=incident_update \
  --context account.plan=enterprise \
  --context incident.active=true
```

```text
value key: enterprise_incident
value: {"channels":["email","slack","in_app"],"delivery":"immediate","fallback_channel":"email","respect_quiet_hours":false}
```

A standard account receiving the same incident update still gets the default
digest policy. That is the part I want visible during review: the policy
changes only for the named condition.

## Use The Policy In The App

The app should resolve the delivery policy at the boundary where it is about to
enqueue or send a notification. Rototo returns the reviewed policy. The
notification service applies recipient preferences, consent, quiet-hour
calculation, provider routing, retries, and logging.

```rust
use serde::Deserialize;

use rototo::{ResolveContext, Workspace};

#[derive(Debug, Deserialize)]
struct DeliveryPolicy {
    delivery: String,
    channels: Vec<String>,
    digest_interval_hours: Option<u64>,
    respect_quiet_hours: bool,
    fallback_channel: String,
}

async fn delivery_policy(
    workspace: &Workspace,
    account_plan: &str,
    notification_kind: &str,
    incident_active: bool,
) -> Result<DeliveryPolicy, Box<dyn std::error::Error>> {
    let context = ResolveContext::from_json(serde_json::json!({
        "account": {
            "plan": account_plan
        },
        "notification": {
            "kind": notification_kind
        },
        "incident": {
            "active": incident_active
        }
    }))?;

    let resolution = workspace
        .resolve_variable("notification-delivery-policy", &context)
        .await?;
    let value_key = resolution.value_key.clone();
    let policy: DeliveryPolicy = serde_json::from_value(resolution.value)?;

    println!(
        "selected notification-delivery-policy `{}` from {:?}",
        value_key,
        workspace.source_fingerprint()
    );

    Ok(policy)
}
```

The selected value is observable. Logs and traces can show which policy key was
used, which workspace version supplied it, and which runtime facts led there.

## Keep The State Somewhere Else

This is the line I would keep coming back to in review: rototo owns reviewed
delivery rules, not notification state.

At this boundary, rototo should own:

- default delivery modes;
- channels allowed for a class of notification;
- whether a policy respects quiet hours;
- fallback behavior for a named notification path;
- policy differences by account class or operating state.

Keep these in the notification system or adjacent operational systems:

- recipient subscriptions and opt-outs;
- verified addresses and consent records;
- per-recipient quiet-hour windows;
- message IDs and delivery attempts;
- provider failures and retries;
- audit logs and customer support history.

That keeps the policy reviewable without pretending that a configuration
workspace is a delivery database. The notification service still owns the live
work. Rototo gives it a typed, versioned, explainable policy to apply.