rototo
DocsLearn
Learn

Incident Banner

The first two examples used scalar values: an integer account limit and a boolean operational switch. The next step is a structured value. Some runtime configuration is not a single number or true/false decision; it is a small object the app needs to trust before it renders it.

An incident banner is a good example. During a regional support incident, the service may need to show a banner to affected accounts. A malformed banner is user-visible, so I want the workspace to validate the object before the app ever loads it.

In this example, rototo owns the reviewed decision and selected payload. The app still owns placement, styling, localization, and whether a given page should render a banner at all.

Start With A Resource-Backed Variable

Create a workspace with a variable and a resource template:

rototo init communications-config --variable support-banner
rototo init communications-config --resource support-banner

The variable will select a named banner object. The resource will define the schema and hold the objects the variable can select.

Replace communications-config/variables/support-banner.toml:

schema_version = 1

description = "Support banner shown during operational incidents"
type = "resource:support-banner"

[resolve]
default = "none"

Then replace communications-config/resources/support-banner.toml:

schema_version = 1

description = "Support banner payloads"
schema = "../schemas/support-banner.schema.json"

The variable now has a type, but its values live as resource objects. Resolution stays in the variable, while object validation belongs to the resource.

Define The Object Shape

Before writing banner objects, define the shape the app is willing to consume. Replace communications-config/schemas/support-banner.schema.json:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["enabled"],
  "properties": {
    "enabled": { "type": "boolean" },
    "severity": { "type": "string", "enum": ["info", "warning", "critical"] },
    "title": { "type": "string", "minLength": 1 },
    "message": { "type": "string", "minLength": 1 },
    "link": { "type": "string" }
  },
  "additionalProperties": false,
  "allOf": [
    {
      "if": {
        "properties": { "enabled": { "const": true } },
        "required": ["enabled"]
      },
      "then": {
        "required": ["severity", "title", "message"]
      }
    }
  ]
}

The schema makes two production expectations explicit. A disabled banner can be small. An enabled banner must include the fields the app needs to render something coherent.

Add The Banner Objects

Rename the generated object file from communications-config/resources/support-banner-objects/default.toml to communications-config/resources/support-banner-objects/none.toml, then replace its contents:

enabled = false

Create communications-config/resources/support-banner-objects/eu-incident.toml:

enabled = true
severity = "warning"
title = "Delayed support responses"
message = "Support response times are slower than usual in your region."
link = "https://status.example.com"

These file stems, none and eu-incident, are the value keys the variable can select. Rototo validates both objects against the resource schema during lint.

Run lint and resolve the default path:

rototo lint communications-config
rototo resolve communications-config --variable support-banner

With no runtime context, the workspace selects none:

value key: none
value:
  enabled: false

That default matters. The app can ask for support-banner on every request and receive a valid object, even when there is nothing to show.

Name The Affected Condition

Now add the runtime condition. In this incident, only accounts operating in the European region should see the banner.

Create communications-config/qualifiers/eu-accounts.toml:

schema_version = 1
description = "Accounts operating in the European region"

[[predicate]]
attribute = "account.region"
op = "eq"
value = "eu"

Then update the variable so the named condition selects the incident payload:

schema_version = 1

description = "Support banner shown during operational incidents"
type = "resource:support-banner"

[resolve]
default = "none"

[[resolve.rule]]
qualifier = "eu-accounts"
value = "eu-incident"

The variable now says the operational policy directly: no banner by default; show the incident banner for the affected account region.

Generate The Context Contract

The qualifier introduced a context path, account.region. Generate the context schema after that path exists:

rototo init communications-config --context

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

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

Now lint again:

rototo lint communications-config

This catches both sides of the contract: the app context shape used by the qualifier, and the selected banner object shape used by the app.

Resolve The Two Paths

Unaffected accounts get the valid empty banner:

rototo resolve communications-config \
  --variable support-banner \
  --context account.region=us
value key: none
value:
  enabled: false

Affected accounts get the incident payload:

rototo resolve communications-config \
  --variable support-banner \
  --context account.region=eu
value key: eu-incident
value:
  enabled: true
  severity: warning
  title: Delayed support responses

The text is not the main thing. What matters is that the selection and the payload are both reviewable, validated, and explainable.

Deserialize In The App

The app should deserialize the selected value into the type it renders. That keeps the boundary crisp: rototo returns a validated JSON value, and the app turns that value into application behavior.

use serde::Deserialize;

use rototo::{ResolveContext, Workspace};

#[derive(Debug, Deserialize)]
struct SupportBanner {
    enabled: bool,
    severity: Option<String>,
    title: Option<String>,
    message: Option<String>,
    link: Option<String>,
}

async fn support_banner_for_request(
    workspace: &Workspace,
    account_region: &str,
) -> Result<Option<SupportBanner>, Box<dyn std::error::Error>> {
    let context = ResolveContext::from_json(serde_json::json!({
        "account": {
            "region": account_region
        }
    }))?;

    let resolution = workspace
        .resolve_variable("support-banner", &context)
        .await?;
    let value_key = resolution.value_key.clone();
    let banner: SupportBanner = serde_json::from_value(resolution.value)?;

    println!(
        "selected support-banner `{}` from {:?}",
        value_key,
        workspace.source_fingerprint()
    );

    if banner.enabled {
        Ok(Some(banner))
    } else {
        Ok(None)
    }
}

In a real service, I would emit the selected value key and workspace fingerprint through the same observability path I use for the request. When a customer asks why a banner appeared, the answer should point back to the workspace version and rule that selected it.

Keep Rendering In The App

Rototo should not become a content management system. It fits here because the banner changes production behavior and needs release discipline.

This is a good rototo boundary when:

Keep these concerns in the app:

That keeps the line clear. The workspace owns the validated operational payload. The app owns the product experience around it.